披露说明:Bun 于 2025 年 12 月被 Anthropic 收购。我和 Bun 团队的其他成员目前都在 Anthropic 工作。我在 Rust 重写过程中大量使用了预发布版本的 Claude Fable 5。
Bun 最初是将 esbuild 的 JavaScript 与 TypeScript 转译器从 Go 逐行移植到 Zig。我于 2021 年 4 月 16 日写下了第一行 Zig 代码。在 Hacker News 上看到单页的 Zig 语言参考文档后,我对其底层控制能力和对性能的极致追求感到非常兴奋,于是押注了 Zig。
从一开始,Bun 的范围就非常庞大:
- JavaScript、TypeScript 和 CSS 的转译器、压缩器和打包器
- 兼容 npm 的包管理器
- 类似 Jest 的测试运行器
- 兼容 Node.js 和 TypeScript 的模块解析
- HTTP/1.1 和 WebSocket 客户端
- Node.js API 实现,如 fs、net、tls 以及数十个其他模块
Bun 的初始版本由我在一年内、在奥克兰一间狭小的公寓里、在 LLM 出现之前用 Zig 编写完成。对于像 Bun 这样野心勃勃的项目,默认的结局往往是沦为 GitHub 个人主页上已废弃的 side project 墓地。Zig 让 Bun 成为可能。如果不是因为 Zig,我绝不可能在一年内完成这么多工作。
如今,Bun 的 CLI 每月下载量超过 2200 万次。像 Claude Code 和 OpenCode 这样的流行工具都选择 Bun 作为运行时。Vercel、Railway、DigitalOcean 等平台均对 Bun 提供一级支持。
Bun 的范围也对稳定性构成了挑战。以下是我们在 Bun v1.3.14 中修复的一小部分 bug:
- 在 node:zlib 中,当对 zlib、Brotli 或 Zstd 流调用 .reset(),而此时线程池上仍有异步 .write() 正在进行时,引发堆释放后使用(heap-use-after-free)崩溃
- 在 node:zlib 中,当 onerror 回调对原生句柄执行了重入的 write() 后紧接着 close() 时,引发释放后使用(use-after-free)崩溃
- 在 node:http2 中,当重入的 JS 回调(例如在超时监听器、options getter 或 write 回调内调用 session.request())触发了哈希表重哈希(hashmap rehash),导致内部流指针失效时,引发释放后使用崩溃
- 在 UDPSocket.send() 和 sendMany() 中,当 valueOf() 或 toString() 回调中的用户代码在数据捕获与实际发送之间分离(detach)了 ArrayBuffer 时,引发释放后使用问题
- 当参数强制转换过程中,valueOf 回调函数分离或调整底层 ArrayBuffer 的大小时,Buffer#copy 和 Buffer#fill 会出现崩溃和越界读取问题。
- 当通过用户 JS 回调在迭代中途更改套接字连接状态时,UDPSocket.sendMany() 会出现堆越界写入问题。
- crypto.scrypt 中存在内存泄漏:当输出缓冲区分配失败时,回调函数以及受保护的密码/盐缓冲区从未被释放。
- SSLWrapper.init 在错误路径上泄漏了 strdup 处理的密码短语。
- tlsSocket.setSession() 中存在内存泄漏:每次调用都会泄漏一个 SSL_SESSION(约 6.5 KB),原因是在 d2i_SSL_SESSION 之后缺少 SSL_SESSION_free。
- 存在内存泄漏:fs.watch() 的监视器在调用 .close() 后从未被垃圾回收,原因是引用计数下溢,导致每个监视器被永久固定为 GC 根节点。
- CSS 解析器中存在双重释放崩溃:当 background-clip 包含厂商前缀和多层背景时。
- DuplexUpgradeContext 从未被释放——每次调用 tls.connect({ socket: duplex }) 都会完全泄漏。
- MessageEvent 中存在竞态条件崩溃:当 GC 标记线程在通过 BroadcastChannel 或 MessagePort 并发访问时,可能观察到 m_data 中出现撕裂的变体。
我们本可以继续一个一个地修复这类 Bug,永远没完没了,但我们欠那些信赖我们的用户一个更好的做法,那就是系统性地防止这类 Bug 再次出现。
我们已经在做的事情
- 我们修补了 Zig 编译器,增加了 Address Sanitizer 支持。每次提交都会用 ASAN 运行测试套件。
- 我们在 Windows 上提供 Zig 安全检查版 ReleaseSafe 构建。
- 我们使用 Fuzzilli(V8 和 JavaScriptCore 使用的 JavaScript 引擎模糊测试工具)全天候对 Bun 的运行时 API 进行模糊测试。
- 我们有大量的端到端内存泄漏测试。
这已经比许多项目做的都多。
只要特别聪明,不出错就行了?
我们的 bug 修复列表让人难受,我也厌倦了每晚担心 Bun 的崩溃问题。我并不因此责怪 Zig——Zig 的其他用户并没有我们遇到的这些 bug,而且将垃圾回收与手动管理内存混合使用,对软件来说是一件足够罕见的需求,以至于没有任何语言真正为此设计过。如果没有 Zig,我们不可能走到今天这一步,我会永远心存感激。直到最近,编程语言的选择对于像 Bun 这样的项目来说还是一条单行道。
JavaScript 是一种垃圾回收语言,而现代 JavaScript 引擎(如 JavaScriptCore 和 V8)对异常处理和垃圾回收有严格的规定。Zig 和 C 一样,不会替你管理内存,对许多项目来说,这种权衡正是使用 Zig 的好理由。Zig 没有构造函数/析构函数,大多数清理工作都期望在每个调用点显式地用 defer 来编写。
对于 Bun 来说,正确管理垃圾回收值和手动管理值的生命周期一直是稳定性问题的主要来源——最常见的是轻微的内存泄漏,偶尔也会导致崩溃。每次内存分配都必须仔细审查:这些字节在哪里释放?如何确保它只被释放一次?我们是否正确检查了 JavaScript 异常?这个垃圾回收指针对于保守的栈扫描器是否可见?这块内存是垃圾回收管理的还是手动管理的?
对于稳定性问题,越早发现越好。模糊测试在代码合并后进行。持续集成在代码推送时触发。运行时安全检查与地址清理器在代码运行时运行(希望在开发阶段,在持续集成之前)。
减少这类问题的一种常见方法是确保需要清理的代码始终只运行一次。Zig 被设计成一种没有隐藏控制流的简单语言,因此它更倾向于使用显式的 defer 关键字在作用域结束时运行代码,而不是 C++ 的隐式 ~Destructor 或 Rust 的隐式 Drop。
| 语言 | 清理 |
|---|---|
| Zig | defer, errdefer |
| C++ | ~Destructor, &&Move |
| Rust | Drop |
对于 Zig 代码,我们究竟应该在什么时候执行清理代码?如果我们将同一个 `*T` 传递给许多不同的函数,我们如何知道它何时不再可访问从而可以被清理?当某些函数在调用之后仍需继续引用这块内存时,该如何处理?我们目前的方案是以下几种方式混合使用:
- arena 生命周期,在这种模式下,内存可访问的作用域是清晰的(解析器的状态不会逃逸出调用函数,因此 AST 节点在这方面是不错的选择)
- 引用计数
- 非常细致地留意
许多项目选择通过风格指南来回答这类问题。TigerBeetle 的 TigerStyle 是 Zig 领域的一个例子,Google 长达 31,000 字的 C++ 风格指南则是另一个。风格指南面临的挑战在于执行。如何确保风格指南被遵守?过去,代码审查是答案,并通过 linter 和静态分析工具尽最大努力来执行。
拥有一套严格的风格指南,并在类型系统中明确写出清晰的归属预期,对 Bun 来说是一个可行的选项。由于 Zig 没有运算符重载,我们最终可能会看到很多类似这样的代码:
fnfoo(a_ptr: SharedPtr(TCPSocket)) !void {
const a: *TCPSocket = a_ptr.get();
defer a_ptr.deref();
const b = trydo_something_with_a(a);
defer b.deref();
// ...
}
这比我们预期的 Zig 代码要更不顺手:
fnfoo(a: *TCPSocket) !void {
const b = trydo_something_with_a(a);
// ...
}
那 C/C++ 呢?
Bun 大约 20% 的代码是用 C++ 编写的,并且 Bun 嵌入了多个 C/C++ 库:
- JavaScriptCore——驱动 Safari 的 JavaScript 引擎
- uWebSockets 和 usockets——我们的 HTTP/WebSocket 服务器和事件循环
- lshpack 和 lsquic——HPACK 和 HTTP/3 库
- BoringSSL——Google 的 OpenSSL 分支
- SQLite
对 Bun 来说,选择 C++ 而不是 Zig 也合情合理。我们可以使用构造函数和析构函数。我们可以删除大量 `extern "C"` 包装代码。
但是,我们仍然要依赖通过代码审查来执行的风格指南,而且即使有 ASAN,内存损坏和内存泄漏仍然会发生。
为什么选择 Rust?
上面列表中的大量错误属于释放后使用、双重释放以及在错误路径中“忘记释放”。在安全的 Rust 中,这些是编译器错误,并且有类似 RAII 的自动清理机制(通过 Drop 实现)。编译器错误比风格指南提供更好的反馈循环。
从历史上看,重写通常是个糟糕的主意。去掉注释后,Bun 是 535,496 行 Zig 代码。用另一种语言重写需要一个小型工程师团队花一整年时间。这意味着在此期间要冻结 bug 修复、安全修复或功能开发。要让某些东西可交付,最稳妥的方法是从 Zig 机械地移植到 Rust,行为改动尽可能少,并使用我们已经在 Bun 上使用的完全相同的测试套件。
幸运的是,Bun 自身的测试套件是用 TypeScript 编写的,这意味着它不依赖于运行时的编程语言。
一整年对用户零影响不是一个我们能够考虑的可行选项。所以,通过代码风格来强制解决稳定性问题是我们当时的最佳选择,也是我们在将受 Rust 启发的智能指针引入 Bun 代码库时的计划。
但老实说,我并不想做这件事。自制的智能指针比 Rust 的人体工学体验更差,而且完全没有那些保证。
那么,如果我花一周时间测试 Anthropic 的新模型能否把 Bun 用 Rust 重写呢?
一开始,我没想到它能行得通。几天后,测试套件中有很高比例的用例开始通过,我看到了新的 Rust 代码与原始的 Zig 代码库有多吻合。我的看法从"这值得一试"变成了"我要合并这个"。
Claude,用 Rust 重写 Bun。
搞砸这事的方法有很多。比如,提示 Claude"用 Rust 重写 Bun。不要犯任何错误。"然后祈祷它能行得通——并不是我做的。
想想一个人会怎么做。第一个大问题是:
增量式重写?还是一次性全部完成?
根据我当初将 esbuild 的转译器从 Go 移植到 Zig(在 Bun 初始版本中,没有使用大语言模型)的经验,一次性全部完成更好。增量重写会引入你希望最终被删掉的临时代码,在中期内会很痛苦。
第二个大问题:怎么做?
我们要如何让 Rust 版的 Bun 与之前的 Bun 保持一致——同样的架构、性能和功能集——同时还能拥有 Rust 的语言特性,比如借用检查器?我们要如何确保团队在重写之后仍然能够维护它?
进行一次重写,让它看起来像是我们把 Zig 代码转译成了 Rust。在 Bun v1.4 发布后,我们可以逐步重构它,减少 unsafe 的使用,使其更像地道的 Rust 代码。
只有这两个是重大问题,其余都是策略层面的。
编写和审查代码的循环
软件工程师日常工作中的很多任务,可以过度简化为循环。
// Pseudocode, not real code:
let task;
while ((task = todoList.pop())) {
const result =task();
const feedback =awaitPromise.all([review(result), review(result)]);
awaitapply(feedback, result);
}
一个任务带有相关的上下文(如 Jira 工单、GitHub issue 等)。结果就是你为修复它而编写的代码。代码审查员审查这些变更,检查是否存在回归和正确性问题。然后你处理反馈。
我使用 Claude Code 中大约 50 个动态工作流,在 11 天内连续运行,将 Bun 用 Rust 重写了。
每个动态工作流都是这样的循环——一个工作流用于:
- 生成一份移植指南,将 Zig 的模式和类型映射到 Rust 的模式和类型
- 机械地将每个 .zig 文件移植为 .rs 文件,遵循 PORTING.md 和 LIFETIMES.tsv
- 修复每个 crate 的编译错误
- 让像 `bun test` 或 `bun build` 这样的子命令能够工作
- 让 Bun 整个测试套件中的每个测试都能通过
- 几次大规模的重构和清理工作
在那 11 天的大部分时间里(以及之后),我监控着工作流——手动读取输出以检查问题和 bug,并提示 Claude 编辑循环来修复问题。
如何审查一个新增代码超过一百万行的 PR?如何开始建立必要的信心,以负责任地合并大量由 LLM 编写的代码?
一个与语言无关的测试套件,包含百万级别的断言,对抗性代码审查,以及当出现问题时,修复生成代码的流程,而不是手动修复代码。
对抗性审查
对抗性审查要求 Claude(在单独的上下文窗口中)全面地找出变更会导致 bug 或不工作的原因。
分割上下文窗口
通常人类场景下,审查代码的人不是编写代码的人。编写代码的人想要合并代码,这可能会使他们的行为偏向于在代码准备好之前就发布。
Claude 也是如此。编写代码的那个 Claude 希望代码被接受。审查代码的那个 Claude 希望找出代码中的问题。
每个实现者配两名或更多名对抗性审查者。审查者的唯一任务:找出错误以及代码无法运行的原因。实现者不做审查。审查者不写代码。
这看起来是什么样子?
如果你正要去做一件又大又贵的事,先降低风险可以节省时间和金钱。
准备工作
在动手写任何代码之前,我花了大约 3 个小时和 Claude 讨论如何把 Zig 代码库的模式紧密映射到 Rust 上。Claude 把这次讨论整理成了一篇 PORTING.md 文档,后来出现在了 Hacker News 上。
下一个问题:如何给手动管理内存的代码添加 Rust 生命周期?
这时我给 Claude 发了类似这样的提示词:
我:让我们启动一个动态工作流,分析代码库中每个结构体字段的适当生命周期。这个工作流应该读取每个文件中每个结构体字段,并追踪控制流。首先,找出在 Rust 中表达生命周期较复杂的结构体字段,然后为该字段提出一个生命周期,接着使用两个对抗性审阅智能体来审查该生命周期,再应用所有反馈,最后序列化为 LIFETIMES.tsv 供其他 Claude 查阅。
然后,对 PORTING.md 和 LIFETIMES.tsv 共同进行一轮对抗性审查,以修正任何相互矛盾的建议并双重检查所有内容。我也手动通读了一遍。
试运行
在要求 Claude 将全部 1,448 个 .zig 文件翻译成 .rs 文件之前,我先只试了 3 个文件。对于这 3 个文件中的每一个,1 名实现者编写新的 .rs 文件,2 名对抗性审查者检查 .rs 文件与 .zig 文件的行为是否一致,并且检查它是否遵循 PORTING.md 和 LIFETIMES.tsv。之后,1 名修复者应用所有建议。
出师不利
我让 Claude 对所有 1,448 个 .zig 文件循环执行工作流,大约两分钟后,其中一个 Claude 在提交之前运行了 git stash。另一个运行了 git stash pop。然后又运行了 git reset HEAD --hard。它们互相干扰!如果我把每个 Claude 放到独立的工作树中,磁盘空间又会不够用,因为 Bun 的 Git 仓库太大了,而且最终所有改动还需要放在一起编译和查看。
于是,我让 Claude 修改工作流,指示 Claude 永远不要运行 git stash 或 git reset 以及任何不能一次性提交特定文件的 git 命令。也不能运行 cargo。任何慢速命令都不行。
然后,Claude 恢复了工作流。而且居然能跑了!但速度太慢了,所以我把它拆分成 4 个工作流分片,每个分片有自己的工作树(总共 4 个工作树),每个分片运行 16 个 Claude 实例来提交和推送文件。
终于开始写代码
得益于所有这些并行化和准备工作,峰值时 Claude 每分钟能写大约 1,300 行代码。每一行代码都经过两名独立的对抗性审查者(也是 Claude)审查,并在提交之前经历一轮修复。不过这些代码当时还完全不能运行。
注意到时间间隔不一致了吗?我忘了提高运行此任务的 EC2 实例的默认 IOPS。只要一个缓慢的 grep 命令,就足以让磁盘读写冻结好几分钟。
把编译器错误当作工作队列
写完所有代码后,我让 Claude 编写一个工作流来修复每一个编译器错误。我们按 crate 逐个进行。
The trickiest class of error was cyclical dependencies.
我们原来的 Zig 代码库是一个编译单元(本质上就是一个 crate)。我想把新的 Rust 代码库拆成大约 100 个 crate,这样 Rust 编译速度会更快,但这需要避免循环依赖,同时尽量少改动原 Zig 实现的代码。我在开始重写 Rust 之前做的那个 PR 还不够充分。但我没有从头再来,而是运行了另一个工作流,去归类那些存在循环依赖的代码应该放在哪里,并全部记录下来——然后再用另一个工作流来完成重构。
修复循环依赖后,暴露了大约 16,000 个编译错误。对于一个人来说这是个天文数字,但对于同时运行的 64 个 Claude 来说并不算疯狂。
为了最大化并行度,工作流对每个 crate 进行了循环处理。
- 对每个 crate,运行 cargo check,将输出按文件分组,并把错误保存到文件中。
- 修复该 crate 内所有编译错误。
- 为该 crate 安排 2 个对抗性审查者。
- 1 个修复者应用修复。
为了防止 Claude 相互干扰,cargo check 只在最开始时运行,而且和其他运行一样,直到最后才使用 git。
又一个假启动。
Claude 把“让所有 crate 都能编译”理解成了“将有编译错误的函数全部留空”。Claude 还开始添加异常冗长的解释性注释来记录权宜之计,所以我给对抗性审查者添加了这条拒绝规则:
如果你需要写一大段注释来解释为什么这个权宜之计是可接受的,那代码本身就有问题——修复代码。
修改了一次提示词,几个小时后,这些情况就不再发生了。
冒烟测试。
模型非常喜欢说“冒烟测试”。
一旦 cargo check 通过了,下一步就是让它编译成功并能够运行 bun --version。然后出现了链接器错误。接着,启动时立刻 panic。
下一个目标是让它能运行 bun test <file>。一旦这个成功了,我们就可以开始跑测试了!接下来是另一个工作流,对 bun CLI 的子命令进行循环:
- 将每个失败的堆栈跟踪连同其子命令一起保存到文件中。
- 对于按子命令分组的每个失败堆栈跟踪,由 1 个 Claude 负责修复。
- 2 个对抗性审查者。
- 1 个修复者应用建议。
让测试套件在本地通过。
这个工作流对测试文件进行循环处理。
在代码库中按文件夹将大约 100 个随机测试文件分散到 4 个工作树中。对于每个失败的测试,将堆栈跟踪和错误保存到文件中,1 名实现者提出修复方案,2 名对抗性审阅者,然后 1 名修复者应用补丁。
甚至更多误启动
我们的测试套件包含大量内存泄漏测试,以及少数需要超过一分钟才能完成的集成测试——例如:一个测试会运行 `next dev` 并检查热模块重载能否 100 次捕捉到变更。其中几个测试在 debug 构建中会超时。
我们还有压力测试,它们会耗尽机器上的最大 TCP 套接字数、向磁盘读写数 GB 数据,以及生成大约 1 万个进程。
这需要的隔离强度远超"请求"级别,因此我们使用了 `systemd-run`(cgroups)来限制内存和 CPU 使用,并隔离 pid 命名空间。不过机器还是多次耗尽磁盘空间并崩溃。
让测试套件在 CI 中通过
首次 CI 运行两天后,失败列表从 972 个测试文件减少到 23 个。又过了一天半,Linux 全面变绿——这是第一次让人感觉这个 Rust 重写真的能行。
合并之前的其余时间都进展得很顺利。工作流程就是不断循环,为每个平台修复 CI 测试失败的问题,直到不再有任何测试失败为止。还有几项与 Windows 相关的清理工作流,用于去重代码、减少不安全用法,并进行一些常规的代码清理。
合并 Rust 重构版本
一旦 Bun 的整个测试套件在所有平台上都通过了 CI(而且我手动验证了测试确实在运行,没有被跳过),我在本地运行了一堆命令进行测试——然后我按下了合并按钮。
合并到主分支并不是一个带版本号的发布。此时,我有足够的信心继续推进并确认采用这个重构版本,但还不足以发布它。
数据统计
峰值时,我们同时运行了 4 个这样的工作流,每个工作流位于独立的 worktree 中,每个工作流使用 16 个 Claude。同时大约有 64 个 Claude 在运行。
0 个测试被跳过或删除
11 天(5 月 3 日 → 5 月 14 日合并)· 6,778 个提交
| 平台 | expect() 调用次数 | 测试用例数 | 文件数 |
|---|---|---|---|
| Debian 13 x64 | 1,386,826 | 60,624 | 4,174 |
| macOS 14 arm64 | 1,259,953 | 58,850 | 4,175 |
| Windows 2019 x64 | 1,007,544 | 57,337 | 4,173 |
合并之前,这消耗了 59 亿未缓存的输入 token、6.9 亿输出 token 以及 720 亿缓存的输入 token 读取——按 API 定价计算约为 165,000 美元。如果手工来做,我认为这需要 3 名对整个代码库有完整了解的工程师花大约一年时间,而在此期间我们无法改进 Node.js 兼容性、修复 bug、修复安全问题或实现新功能。我们绝不会那样做。现实的选择是什么也不做,然后永远继续修复本文开头提到的那些 bug。
这是当下技术可能性的最前沿。我使用了 Claude Fable 5(神话级模型)的预发布版本。Claude Code 的动态工作流让 64 个 Claude 实例持续运行了 11 天(否则我不得不自己编写工具链来实现这一点)。
工作仍在继续
自合并 Rust 移植版以来,我们已完成 11 轮来自 Claude Code Security 的安全审查,并修复了所有发现的问题。
我们还为 Bun 中的每一个解析器添加了 24/7 覆盖引导的模糊测试——包括 JavaScript、TypeScript、JSX、CSS、JSON5、JSONC、TOML、YAML、Markdown、INI、Bun Shell 脚本、semver 范围、.patch 文件和 CSS 颜色。模糊测试器会自动将发现的缺陷发送给 Claude,由其提交一个包含复现和修复的 PR,再由人工审查这些 PR。到目前为止,它已对解析器执行了 1000 亿次测试,由此产生了约 15 个 PR。
在撰写本文时,Bun 约 4% 的 Rust 代码位于 unsafe 块中(约 27,000 行中约 13,000 个 unsafe 关键字,总代码约 780,000 行),其中 78% 的 unsafe 块只有一行——要么是来自 C++ 的指针,要么是对 C 库的一次调用。我预计这个数字会随着我们从忠实的 Zig 移植版(其中没有可 grep 的 unsafe 关键字)重构为惯用 Rust 而逐步下降,但由于我们会继续使用 JavaScriptCore 等 C 和 C++ 库,因此它的 unsafe 代码量将始终高于纯 Rust 项目。
移植过程中的失误
Rust 重写的重点是稳定性,但要交付如此大规模的改动而不引入任何回归问题是不可能的。
这次重写引入了 19 个已知的回归问题,每个问题均已修复。
大多数回归问题源于那些在两种语言中语法相同但语义不同的代码。
debug_assert! 内部的副作用
这两段代码看起来相似,但行为不同。Zig 的 assert 是一个函数,因此其参数在每次构建中都会执行。Rust 的 debug_assert! 是一个宏,因此在发布构建中整个表达式会被抹除,包括 insert_stale 调用。
// Zig:
if (dev.framework.react_fast_refresh) |rfr| {
assert(try dev.client_graph.insertStale(rfr.import_source, false) ==IncrementalGraph(.client).react_refresh_index);
}
// Rust:
if let Some(rfr) = &dev.framework.react_fast_refresh {
debug_assert!(dev.client_graph.insert_stale(&rfr.import_source, false)? == react_refresh_index);
}
insert_stale 会将一个文件添加到前端开发服务器的热重载图中。在发布构建版本中,该功能停止运行,导致某些使用了 React 的 HTML 路由项目在热重载文件失效时出现 HMR 故障:无法解构 'k' 的 'isLikelyComponentType' 属性。调试构建版本正常工作。#30678
奇数长度的切片
Bun 的 Zig 辅助函数 reinterpretSlice(u16, bytes)(早于支持切片的内置类型转换)使用了 @divTrunc 并忽略了一个尾随的奇数字节。而 bytemuck::cast_slice 会对此抛出 panic。当遇到 UTF-16 字节顺序标记后跟奇数个字节时,Blob.text() 不再返回字符串,而是导致进程 panic。我们恢复了对奇数字节的忽略处理:&buf[..buf.len() & !1]。#31188
边界检查
在 macOS 和 Linux 上,我们使用 ReleaseFast 编译 Bun 的 Zig 代码,这会移除边界检查。而 Rust 的发布构建版本保留了边界检查。
Bun 的模块解析器将长文件名插入到一个全局列表中,该列表会溢出到溢出块中。原始的 Zig 代码将每个块的大小设为 count / 4,或 2048。移植后的代码留下了一个占位符:
/// ... so use a nonzero stand-in until Phase B threads the
/// per-instantiation value through.
pubconstBSS_OVERFLOW_BLOCK_SIZE:usize=64;
这会将已插文件名数量的上限从 840 万降低到 270,272,而实际项目会达到这个上限,同时也使得我们从 Zig 移植过来的 ptrs[4095] 差一错误变得可触发。Rust 会 panic 而不是写出界。如果我们使用 ReleaseSafe(我们仅在 Windows 上使用),Zig 在这种情况下也会 panic。#31503
编译期格式字符串
Output.pretty 会将 <r> 和 <d> 颜色标记重写为 ANSI 转义序列。在 Zig 中,fmt 是编译期确定的,因此标记在参数替换之前就已经被处理掉了。Rust 函数没有编译期参数,所以 Output::pretty 只能看到最终的字符串,并将标记也重写到了参数上。
// Zig:
pubinlinefnpretty(comptime fmt: string, args: anytype) void;
Output.pretty("<r>{f}<r>", .{hyperlink});
// Rust:
pubfnpretty(payload: impl PrettyFmtInput);
Output::pretty(format_args!("<r>{}<r>", hyperlink));
bun update -i 将包名打印为 OSC 8 超链接,以 ESC \ 结尾。这个反斜杠正好位于尾部 <r> 的 < 之前,标记解析器会吃掉它,导致 r 作为文本打印出来。

在 Rust 中,这必须是一个宏:bun_core::pretty!("<r>{}<r>", hyperlink)。#30693
Bun 在 Rust 中表现得更好
到目前为止,Bun v1.4.0 修复了 v1.3.14 中可复现的 128 个 bug。这些 bug 涵盖了内存泄漏、崩溃以及帮助文本颜色错误等问题。
减少内存使用
Rust 有一个强大的语言级内存清理工具:Drop。当实现了 Drop 后,每次值离开作用域时,drop 函数会被自动调用。
implDropforBytes {
fndrop(&mutself) {
if!self.pinned.is_empty() {
JSC__JSValue__unpinArrayBuffer(self.pinned);
}
}
}
在 Zig 中,可以使用 defer 在作用域结束时运行代码:
const bytes: ArrayBuffer = try .fromPinned(global, value);
defer bytes.unpin();
在 Zig 中,需要在每个可能需要清理的调用点单独添加 defer。很容易忘记清理(导致内存泄漏),或者在很少到达的错误处理代码中运行两次清理代码(导致双重释放)。在 Rust 中,当值不再可访问时 Drop 会自动运行——用“没有隐藏的控制流”来换取防止常见的易犯错误。
Drop 修复了 Bun 中与错误处理代码中文件路径相关的几个内存泄漏。
我们修复了每一个可检测到的内存泄漏
我们改进了 Bun 的 LeakSanitizer 集成,以跟踪所有原生代码的内存分配。
举个例子:每次进程内的 Bun.build() 调用都会泄漏数兆字节的内存——解析后的源码文本和 AST 符号表,它们的生命周期超过了所属的构建。
// Bundle the same 60-module project 2,000 times in one process
for (let i =0; i <2_000; i++) {
await Bun.build({
entrypoints: ["./index.js"],
minify:true,
sourcemap:"external",
});
}
在 Bun v1.3.14 中,每次构建会永久泄漏约 3 MB——像每次请求都进行打包的开发服务器这样的工具最终会耗尽内存。在 Bun v1.4.0 中,内存趋于平稳:
| 构建次数 | Bun v1.3.14 | Bun v1.4.0 |
|---|---|---|
| 500 | 1,914 MB | 526 MB |
| 1,000 | 3,506 MB | 586 MB |
| 1,500 | 5,097 MB | 608 MB |
| 2,000 | 6,745 MB | 609 MB |
之前一次在 Zig 中实现这个功能的尝试没有被合并,因为缺少 Drop 的等价物,这使得对合并缺乏信心。
更小的二进制体积
Rust 重写中的初始更改使 Windows 上的二进制体积减少了 3.8 MB,macOS 上减少了 5.5 MB,Linux 上减少了 6.8 MB。这很大程度上是因为我们在 Zig 代码中使用了过多的 comptime。
pic.twitter.com/RQiMNMNo8C
— Bun (@bunjavascript) May 18, 2026
在初步瘦身之后,团队探索了更多减少二进制体积的机会,使用了链接器优化,如相同代码折叠、移除 ICU 中未使用的数据,以及使用 zstd 字典按需延迟解压缩 libicu 的小部分内容。
结合 Rust 重写、ICU 更改和相同代码折叠,Bun 的二进制体积在 Linux 和 Windows 上缩减了约 20%。
| 版本 | 平台 | 体积 |
|---|---|---|
| Bun v1.4.0 (canary) | Windows | 76 MB |
| Bun v1.3.14 | Windows | 94 MB |
| Bun v1.4.0 (canary) | Linux | 70 MB |
| Bun v1.3.14 | Linux | 88 MB |
减少了栈空间使用量
Bun 中的 TOML 解析器,以及其他所有递归下降解析器(JSON、YAML、JavaScript、TypeScript 等)现在使用更少的栈空间。
这在合并 Rust 重构代码之前导致了一些测试失败:
bun test v1.3.14-canary.1 (e99311e58)
.......
105| });
106|
107|it("Bun.TOML.parse throws on deeply nested inline tables instead of crashing", () => {
108|const depth =25_000;
109|const deepToml ="a = "+"{ b = ".repeat(depth) +"1"+" }".repeat(depth);
110|expect(() => Bun.TOML.parse(deepToml)).toThrow(RangeError);
^
error: expect(received).toThrow(expected)
Expected constructor: RangeError
Received functiondidnotthrow
Receivedvalue: {
a: {
b: {
b: {
b: {
b: {
b: {
b: {
b: {
b: [Object...],
},
},
},
},
},
},
},
},
}
at <anonymous> (/var/lib/buildkite-agent/build/test/js/bun/resolve/toml/toml.test.js:110:42)
✗ Bun.TOML.parse throws on deeply nested inline tables instead of crashing [2907.64ms]
Rust 的 LLVM IR 代码生成会在栈变量不再使用时发出 LLVM 的 `llvm.lifetime.start` 和 `llvm.lifetime.end` 内联函数,这让 LLVM 能够重用栈空间槽位。这使得具有嵌套作用域的大型函数能够显著减少栈空间使用。
此前,我们通过将特别大的函数重构为多个小函数,手动绕过一个未解决的问题。
快 2% 到 5%
Rust 支持 C/C++ 与 Rust 之间的跨语言链接时优化,能够跨编程语言进行内联(这有多酷!!)。
我们在 Linux x64(EC2,Xeon Platinum 8488C)上对 Bun v1.3.14 与 Bun v1.4.0 进行了基准测试。HTTP 吞吐量使用 oha 测试 hello-world 服务器,应用工作负载使用 hyperfine 测量。
HTTP 吞吐量(req/s,3 轮平均)
| 服务器 | Bun v1.3.14 | Bun v1.4.0 | 变化率 |
|---|---|---|---|
| Bun.serve | 169.6k | 177.7k | +4.8% |
| node:http | 103.8k | 108.5k | +4.5% |
| Elysia | 158.9k | 163.3k | +2.8% |
| express | 64.5k | 66.6k | +3.2% |
| fastify | 91.5k | 95.9k | +4.8% |
应用 / CLI(hyperfine)
| 工作负载 | Bun v1.3.14 | Bun v1.4.0 | 变化率 |
|---|---|---|---|
| next build | 13.62 秒 | 13.03 秒 | +4.5% |
| vite build(tsc + vite) | 1.69 秒 | 1.65 秒 | +2.2% |
| tsc -b --force | 0.94 秒 | 0.89 秒 | +4.7% |
生产环境
Prisma 在 Bun 的 Rust 重写上发布了 Prisma Compute 公开测试版。
“我们遇到了内存泄漏,以及虚拟机暂停并恢复后无法恢复的连接池。当 Rust 重写版本出现时,我们在同样的故障模式下进行了测试。它完美地处理了这些问题。”——Alexey Orlenko
Claude Code v2.1.181(6 月 17 日发布)及之后版本使用了 Bun 的 Rust 移植版。启动速度在 Linux 上快了 10%,但除此之外几乎没有人注意到。无惊无险是好事。

发布
Bun v1.3.14 是最后一个用 Zig 编写的 Bun 版本。Bun v1.4.0 将是第一个用 Rust 编写的 Bun 版本。它现在可以在 canary 版本中使用——请报告您发现的任何问题:
bun upgrade --canary可维护性
对于我和团队来说,我们新的 Rust 代码库感觉与旧的 Zig 代码库非常相似。例如,下面是一段原始 Zig 代码和新的 Rust 代码:
pubfncanMergeSymbols(
scope: *Scope,
existing: Symbol.Kind,
new: Symbol.Kind,
comptime is_typescript_enabled: bool,
) SymbolMergeResult {
if (existing == .unbound) {
return .replace_with_new;
}
if (comptime is_typescript_enabled) {
// In TypeScript, imports are allowed to silently collide with symbols within
// the module. Presumably this is because the imports may be type-only:
//
// import {Foo} from 'bar'
// class Foo {}
//
if (existing == .import) {
return .replace_with_new;
}
// ...
}
// ...
}
pubfncan_merge_symbol_kinds<constIS_TYPESCRIPT_ENABLED:bool>(
scope_kind:Kind,
existing: symbol::Kind,
new: symbol::Kind,
) ->SymbolMergeResult {
if existing == symbol::Kind::Unbound {
returnSymbolMergeResult::ReplaceWithNew;
}
ifIS_TYPESCRIPT_ENABLED {
// In TypeScript, imports are allowed to silently collide with symbols within
// the module. Presumably this is because the imports may be type-only:
//
// import {Foo} from 'bar'
// class Foo {}
//
if existing == symbol::Kind::Import {
returnSymbolMergeResult::ReplaceWithNew;
}
// ...
}
// ...
}
任何理解原始 Zig 代码的人都能理解机械翻译后的 Rust 代码。我审查了原始的 Rust 重写 PR,检查对抗性代码审查智能体是否正确捕捉了 Zig 代码与 Rust 代码之间的差异,确保移植指南和生命周期指南得到遵守,并且自己也手动将大量代码与 Zig 和 Rust 进行了对比阅读。
下一步计划
Bun v1.4 让 Bun 更快、更小、占用更少内存,并为团队提供了极为强大的工具,以系统性地提升后续稳定性:Rust 的借用检查器、Miri(在 CI 中运行于越来越大的代码块)、LeakSanitizer,以及针对解析器的 24/7 覆盖引导模糊测试。虽然还有更多内容需要重构,但开局已相当出色。
这次 Rust 重写原本需要一支对代码库有全面背景理解的工程师团队花费一年时间。在一位工程师使用 Fable 并密切监控 Claude Code 的情况下,我们从零开始,在 11 天内实现了所有平台上测试套件 100% 通过。
如今一位工程师能完成的工作比一年前多得多。