Rewriting Bun in Rust
Rewriting Bun in Rust / 使用 Rust 重写 Bun
Disclosure: Bun was acquired by Anthropic in December 2025. I and others on the Bun team work at Anthropic. I used a pre-release version of Claude Fable 5 for much of the Rust rewrite. 披露:Bun 已于 2025 年 12 月被 Anthropic 收购。我和 Bun 团队的其他成员目前都在 Anthropic 工作。在这次 Rust 重写过程中,我大量使用了 Claude Fable 5 的预发布版本。
Bun started as a line-for-line port of esbuild’s JavaScript & TypeScript transpiler from Go to Zig. I wrote my first line of Zig on April 16, 2021. I bet on Zig after seeing the single-page Zig Language Reference on Hacker News and getting really excited about the low-level control and care for performance. Bun 最初是 esbuild 的 JavaScript 和 TypeScript 转译器从 Go 到 Zig 的逐行移植。我于 2021 年 4 月 16 日写下了第一行 Zig 代码。在 Hacker News 上看到单页的 Zig 语言参考手册后,我对它提供的底层控制能力和对性能的极致追求感到非常兴奋,于是决定押注 Zig。
From the start, Bun’s scope was massive: 从一开始,Bun 的目标范围就非常宏大:
- JavaScript, TypeScript, and CSS transpiler, minifier, and bundler JavaScript、TypeScript 和 CSS 转译器、压缩器及打包工具
- npm-compatible package manager 兼容 npm 的包管理器
- Jest-like test runner 类似 Jest 的测试运行器
- Node.js & TypeScript-compatible module resolution 兼容 Node.js 和 TypeScript 的模块解析
- HTTP/1.1 & WebSocket client HTTP/1.1 和 WebSocket 客户端
- Node.js API implementations like fs, net, tls, and dozens of other modules Node.js API 实现(如 fs、net、tls 等数十个模块)
The initial version of Bun was written by me in 1 year, in a cramped Oakland apartment, pre-LLM, in Zig. The default outcome for ambitiously-scoped projects like Bun is joining the graveyard of dead side projects on a GitHub profile page. Zig made Bun possible. I would never have been able to build this much in 1 year if it wasn’t for Zig. Bun 的最初版本是我在奥克兰一间狭窄的公寓里,在 LLM 出现之前,用 Zig 花了一年时间写出来的。像 Bun 这样目标宏大的项目,通常的结局就是成为 GitHub 个人主页上众多“死掉的副项目”墓地中的一员。是 Zig 让 Bun 成为可能。如果不是因为 Zig,我绝不可能在一年内构建出如此庞大的系统。
Nowadays, Bun’s CLI gets over 22 million monthly downloads. Popular tools like Claude Code and OpenCode bet on Bun as their runtime. Vercel, Railway, DigitalOcean and more have 1st-party support for Bun. 如今,Bun 的 CLI 每月下载量超过 2200 万次。Claude Code 和 OpenCode 等热门工具都选择 Bun 作为其运行时。Vercel、Railway、DigitalOcean 等平台也已为 Bun 提供了官方支持。
Bun’s scope has also been a challenge for stability. Here’s a small sample of bugs we fixed in Bun v1.3.14: Bun 的庞大规模也给稳定性带来了挑战。以下是我们修复的 Bun v1.3.14 版本中一小部分 Bug 示例:
- heap-use-after-free crash in node:zlib when calling .reset() on a zlib, Brotli, or Zstd stream while an async .write() is still in progress on the threadpool 在 node:zlib 中,当线程池中仍有异步 .write() 操作时,对 zlib、Brotli 或 Zstd 流调用 .reset() 会导致堆释放后使用(use-after-free)崩溃。
- use-after-free crash in node:zlib when an onerror callback issued a re-entrant write() followed by close() on native handles 在 node:zlib 中,当 onerror 回调触发重入 write() 随后又对原生句柄调用 close() 时,会导致释放后使用崩溃。
- use-after-free crashes in node:http2 when re-entrant JS callbacks (e.g. session.request() inside a timeout listener, an options getter, or a write callback) triggered a hashmap rehash, invalidating internal stream pointers 在 node:http2 中,当重入 JS 回调(例如在超时监听器、选项获取器或写入回调中调用 session.request())触发哈希表重哈希,导致内部流指针失效时,会引发释放后使用崩溃。
- use-after-free in UDPSocket.send() and sendMany() where user code in valueOf() or toString() callbacks could detach an ArrayBuffer between payload capture and the actual send 在 UDPSocket.send() 和 sendMany() 中,用户在 valueOf() 或 toString() 回调中的代码可能在载荷捕获与实际发送之间分离 ArrayBuffer,导致释放后使用。
- crash and out-of-bounds read in Buffer#copy and Buffer#fill when a valueOf callback detaches or resizes the underlying ArrayBuffer during argument coercion 当 valueOf 回调在参数强制转换期间分离或调整底层 ArrayBuffer 大小时,Buffer#copy 和 Buffer#fill 会发生崩溃和越界读取。
- heap out-of-bounds write in UDPSocket.sendMany() when the socket’s connection state changed mid-iteration via user JS callbacks 当 socket 连接状态在迭代过程中通过用户 JS 回调发生改变时,UDPSocket.sendMany() 会发生堆越界写入。
- memory leak in crypto.scrypt where the callback and protected password/salt buffers were never released when the output buffer allocation failed crypto.scrypt 中的内存泄漏:当输出缓冲区分配失败时,回调函数及受保护的密码/盐缓冲区从未被释放。
- SSLWrapper.init leaked the strdup’d passphrase on error paths SSLWrapper.init 在错误路径中泄漏了 strdup 产生的密码短语。
- memory leak in tlsSocket.setSession() where each call leaked one SSL_SESSION (~6.5 KB per call) due to a missing SSL_SESSION_free after d2i_SSL_SESSION tlsSocket.setSession() 中的内存泄漏:由于 d2i_SSL_SESSION 后缺少 SSL_SESSION_free,每次调用都会泄漏一个 SSL_SESSION(每次约 6.5 KB)。
- memory leak where fs.watch() watchers were never garbage collected after .close(), caused by a reference count underflow that permanently pinned each watcher as a GC root fs.watch() 观察者在 .close() 后从未被垃圾回收的内存泄漏,原因是引用计数下溢导致每个观察者被永久固定为 GC 根。
- double-free crash in the CSS parser when background-clip had vendor prefixes and multi-layer backgrounds 当 background-clip 包含供应商前缀和多层背景时,CSS 解析器会发生双重释放(double-free)崩溃。
- DuplexUpgradeContext was never freed — a full leak per tls.connect({ socket: duplex }) DuplexUpgradeContext 从未被释放——每次 tls.connect({ socket: duplex }) 都会导致完全泄漏。
- race condition crash in MessageEvent where the GC marker thread could observe a torn variant in m_data during concurrent access from a BroadcastChannel or MessagePort MessageEvent 中的竞态条件崩溃:当从 BroadcastChannel 或 MessagePort 并发访问时,GC 标记线程可能会观察到 m_data 中损坏的变体。
We could have kept fixing these kinds of bugs one-off in perpetuity, but we owe it to our users counting on us to do better than that, and systematically prevent these kinds of bugs from recurring. 我们本可以永远这样逐个修复此类 Bug,但我们有责任为依赖我们的用户做得更好,并从系统层面防止此类 Bug 再次发生。
What we were already doing 我们已经在做的事情:
- We patched the Zig compiler to add Address Sanitizer support. We run our test suite with ASAN on every commit. 我们修补了 Zig 编译器以增加 Address Sanitizer 支持。我们在每次提交时都会运行带有 ASAN 的测试套件。
- We ship Zig safety-checked ReleaseSafe builds on Windows 我们在 Windows 上发布经过 Zig 安全检查的 ReleaseSafe 构建版本。
- We fuzz Bun’s runtime APIs 24/7 using Fuzzilli, the JavaScript engine fuzzer used by V8 & JavaScriptCore 我们使用 Fuzzilli(V8 和 JavaScriptCore 使用的 JS 引擎模糊测试工具)全天候对 Bun 的运行时 API 进行模糊测试。
- We have a whole lot of end-to-end memory leak tests 我们拥有大量的端到端内存泄漏测试。
- This is more than many projects do. 这已经比许多项目做得多了。
Just be really smart and don’t make mistakes? 难道只要足够聪明、不犯错就行了吗?
Our bugfix list felt bad and I was tired of going to sleep worrying about crashes in Bun. I don’t blame Zig for that - other users of Zig don’t have the bugs we had, and mixing GC with manually-managed memory is an uncommon enough thing for software to need that no language really designs for it. We wouldn’t have gotten this far if not for Zig, and I’ll always be grateful. Until very recently, programming language choice was a one-way decision for a project like Bun. 我们的 Bug 修复列表让人感觉很糟糕,我厌倦了每天带着对 Bun 崩溃的担忧入睡。我并不责怪 Zig——其他 Zig 用户并没有遇到我们这样的 Bug,而且将垃圾回收(GC)与手动内存管理混合使用在软件开发中并不常见,以至于没有哪种语言专门为此进行设计。如果不是因为 Zig,我们不可能走到今天,我将永远心存感激。直到最近,对于像 Bun 这样的项目来说,编程语言的选择还是一条单行道。
JavaScript is a garbage-collected language and modern JavaScript engines like JavaScriptCore (and V8) have strict rules around exception handling and the garbage collector. Zig, like C, doesn’t manage memory for you and this is a tradeoff that for many projects is a great reason to use Zig. Zig does not have constructors/destructors, and most cleanup is expected to be written out explicitly at each call site with defer. JavaScript 是一种垃圾回收语言,而现代 JavaScript 引擎(如 JavaScriptCore 和 V8)对异常处理和垃圾回收有严格的规则。Zig 和 C 一样,不会为你管理内存,对于许多项目来说,这正是选择 Zig 的重要原因。Zig 没有构造函数/析构函数,大多数清理工作都需要在每个调用点使用 defer 显式编写。
For Bun, correctly handling the lifetimes of garbage-collected values and manually-managed values has been a major source of stability issues - most often small memory leaks and occasionally, crashes. Every memory allocation has to be meticulously reviewed. Where do these bytes get freed? How do we ensure it only gets freed once? Did we check for JavaScript exceptions properly? Is this garbage-collected pointer visible to the conservative stack scanner? Is this garbage collected memory or manually managed memory? 对于 Bun 来说,正确处理垃圾回收值和手动管理值的生命周期一直是稳定性问题的主要来源——通常是小的内存泄漏,偶尔也会导致崩溃。每一处内存分配都必须经过仔细审查。这些字节在哪里释放?如何确保它只被释放一次?我们是否正确检查了 JavaScript 异常?这个垃圾回收指针是否对保守式栈扫描器可见?这是垃圾回收内存还是手动管理的内存?
For stability issues, knowing as early as possible is best. Fuzzing happens after code is merged. CI happens when code is pushed. Runtime safety checks & address sanitizer happens when code is run (hopefully in development, before CI). 对于稳定性问题,越早发现越好。模糊测试在代码合并后进行,CI 在代码推送时进行,运行时安全检查和地址清理器在代码运行时(最好是在开发阶段,CI 之前)进行。
One common way to reduce this class of issue is to ensure cleanup code is always run exactly once for code that needs it. Zig is designed to be a simple language with no hidden control flow, and so it prefers the explicit defer keyword to run code at the end of a scope over C++‘s implicit ~Destructor or Rust’s implicit Drop. 减少此类问题的一种常见方法是确保清理代码始终只运行一次。Zig 被设计为一种没有隐藏控制流的简单语言,因此它更倾向于使用显式的 defer 关键字在作用域结束时运行代码,而不是 C++ 的隐式析构函数(~Destructor)或 Rust 的隐式 Drop。
| Language | Cleanup |
|---|---|
| Zig | defer, errdefer |
| C++ | ~Destructor, &&Move |
| Rust | Drop |
For Zig code, when exactly should we be running th 对于 Zig 代码,我们究竟应该在何时运行……