用 Rust 重写 Bun
原文链接:bun.com/blog/bun-in…
原文作者:Jarred Sumner
写于 2026 年 7 月 8 日
披露:Bun 于 2025 年 12 月被 Anthropic 收购。我与 Bun 团队的其他人在 Anthropic 工作。这次 Rust 重写中的大量工作,我使用了 Claude Fable 5 的预发布版本。
Bun 起步时,是把 esbuild 的 JavaScript 与 TypeScript 转译器从 Go 逐行移植到 Zig。我写下第一行 Zig 是在 2021 年 4 月 16 日。我在 Hacker News 上看到单页的 Zig 语言参考 后押注 Zig,并对底层控制力以及对性能的用心感到非常兴奋。
从一开始,Bun 的范围就极其庞大,覆盖面非常广:
- JavaScript、TypeScript 与 CSS 转译器、压缩器与打包器
- 兼容 npm 的包管理器
- 类似 Jest 的测试运行器
- 兼容 Node.js 与 TypeScript 的模块解析
- HTTP/1.1 与 WebSocket 客户端
- 诸如
fs、net、tls以及数十个其他模块的 Node.js API 实现
Bun 的最初版本由我在一年内写成,地点是一间局促的奥克兰公寓,在 LLM 出现之前,用的是 Zig。像 Bun 这种野心勃勃、范围巨大的项目,默认结局往往是加入 GitHub 个人页上那些已死副业项目的坟场。Zig 让 Bun 成为可能。若没有 Zig,我绝不可能在一年内构建出这么多东西。
如今,Bun 的 CLI 每月下载量超过 2200 万次。Claude Code 与 OpenCode 等流行工具把 Bun 押作运行时。Vercel、Railway、DigitalOcean 等也已对 Bun 提供一等支持。
Bun 的范围同时也给稳定性带来了挑战。下面是我们在 Bun v1.3.14 中修复的一小部分 bug 样本:
- 在
node:zlib中,当线程池上仍有异步.write()进行时调用 zlib、Brotli 或 Zstd 流的.reset(),导致堆上释放后使用(heap-use-after-free)崩溃 - 在
node:zlib中,当onerror回调发出可重入的write()并随后对原生句柄调用close()时,导致释放后使用(use-after-free)崩溃 - 在
node:http2中,当可重入的 JS 回调(例如超时监听器内的session.request()、options getter,或写回调)触发 hashmap 再哈希、使内部流指针失效时,导致释放后使用(use-after-free)崩溃 - 在
UDPSocket.send()与sendMany()中,用户代码在valueOf()或toString()回调里可能在载荷捕获与实际发送之间拆卸ArrayBuffer,导致释放后使用(use-after-free) - 在
Buffer#copy与Buffer#fill中,当valueOf回调在参数强制转换期间拆卸或调整底层ArrayBuffer大小时,导致崩溃与越界读 - 在
UDPSocket.sendMany()中,当套接字连接状态在迭代中途经用户 JS 回调改变时,导致堆越界写 - 在
crypto.scrypt中,当输出缓冲区分配失败时,回调与受保护的密码/盐缓冲区从未释放,导致内存泄漏 SSLWrapper.init在错误路径上泄漏了经 strdup 的口令- 在
tlsSocket.setSession()中,每次调用因在d2i_SSL_SESSION之后缺少SSL_SESSION_free而泄漏一个SSL_SESSION(每次约 6.5 KB),导致内存泄漏 fs.watch()的监视器在.close()之后从未被垃圾回收,原因是引用计数下溢,把每个监视器永久钉死为 GC 根,导致内存泄漏- 当
background-clip带有厂商前缀与多层背景时,CSS 解析器中发生重复释放(double-free)崩溃 DuplexUpgradeContext从未被释放——每次tls.connect({ socket: duplex })都会完整泄漏一次- 在
MessageEvent中,GC 标记线程可能在来自BroadcastChannel或MessagePort的并发访问期间观察到m_data中被撕裂的 variant,导致竞态条件崩溃
我们本可以永远以打补丁的方式继续修这类 bug,但我们对依赖我们的用户有责任做得更好,并系统性地防止这类 bug 再次出现。
我们已经在做的
- 我们给 Zig 编译器打了补丁以加入 Address Sanitizer 支持。我们在每次提交上都用 ASAN 跑测试套件。
- 我们在 Windows 上发布带 Zig 安全检查的 ReleaseSafe 构建
- 我们使用 Fuzzilli(V8 与 JavaScriptCore 所用的 JavaScript 引擎模糊测试器)对 Bun 的运行时 API 进行 24/7 模糊测试
- 我们有大量端到端的内存泄漏测试
这些措施加在一起,已经比许多项目做得更多。
只要足够聪明、不犯错误?
我们的 bugfix 列表让人难受,我也不想再带着「Bun 会不会崩」的担忧入睡。我不怪 Zig——其他 Zig 用户并没有我们这种 bug,而且把 GC 与手工管理内存混在一起,对软件来说足够少见,几乎没有语言真正为此设计。若没有 Zig,我们走不到今天,我将永远心存感激。直到最近,对像 Bun 这样的项目而言,编程语言选择仍是单向决定。
JavaScript 是一门带垃圾回收的语言,而像 JavaScriptCore(以及 V8)这样的现代 JavaScript 引擎,对异常处理与垃圾回收器有严格规则。Zig 像 C 一样,不会替你管理内存;对许多项目来说,这是选用 Zig 的充分理由。Zig 没有构造函数/析构函数,大多数清理预期要用 defer 在每个调用点显式写出。
对 Bun 而言,正确处理垃圾回收值与手工管理值的生命周期,一直是稳定性问题的主要来源——最常见的是小内存泄漏,偶尔是崩溃。每一次内存分配都必须一丝不苟地审查。这些字节在哪里被释放?我们如何确保它只被释放一次?我们是否正确检查了 JavaScript 异常?这个被垃圾回收的指针对保守栈扫描器可见吗?这是垃圾回收内存还是手工管理内存?
对稳定性问题而言,越早知道越好。模糊测试发生在代码合并之后。CI 发生在代码推送时。运行时安全检查与 Address Sanitizer 发生在代码运行时(但愿是在开发阶段、在 CI 之前)。
减少这类问题的一种常见方式,是确保需要清理的代码始终恰好运行一次。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 没有运算符重载,我们很可能最终会写出大量类似这样的代码:
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();
// 省略
}
这比我们平时期望的那种 Zig 写法更不顺手:
fn foo(a: *TCPSocket) !void {
const b = try do_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?
那份列表中很大一部分 bug 是释放后使用(use-after-free)、重复释放(double-free),以及错误路径上「忘了释放」。在安全 Rust 中,这些是编译器错误,以及带 Drop 的类 RAII 自动清理。编译器错误比风格指南是更好的反馈闭环。
历史上,重写是个糟糕主意。去掉注释后,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 最初版本的经验(没有 LLM),一次全部重写更好。增量重写会加入你希望最终删掉的临时代码,在中短期会很痛苦。
第二个大问题则是:具体要怎么去做这件事?
我们如何让 Rust 版 Bun 仍是原来的 Bun——同样的架构、性能与功能集——同时又拿到 Rust 的语言特性,例如借用检查器?我们如何确保团队在重写之后仍能维护它?
做那种看起来像是把我们的 Zig 代码转译成 Rust 的重写。我们可以在 Bun v1.4 发布之后,再逐步重构以减少 unsafe 使用,并看起来更像惯用 Rust。
那就是仅有的两个大问题。其他都是战术细节。
编写与审查代码的循环
作为软件工程师,日常工程工作中很大一部分可以过度简化成循环。
// 伪代码,不是真实代码:
let task;
while ((task = todoList.pop())) {
const result = task();
const feedback = await Promise.all([review(result), review(result)]);
await apply(feedback, result);
}
一个 task 带有一些关联上下文(Jira 工单、GitHub issue 等)。result 是你为修复它而写的代码。代码审查者 review 这些变更以检查回归与正确性。然后你处理反馈。
我用大约 50 个 Claude Code 中的动态工作流,在 11 天内持续运行,完成了用 Rust 重写 Bun。
每个动态工作流都是这样的循环——分别对应:
- 生成一份移植指南,把 Zig 模式与类型映射到 Rust 模式与类型
- 机械式地把每个
.zig文件移植为.rs文件,匹配 PORTING.md 与 LIFETIMES.tsv - 修复每个 crate 的编译器错误
- 让
bun test或bun build等子命令能工作 - 让 Bun 整个测试套件中的每一项测试都通过
- 若干大规模重构与清理遍次
在那 11 天的大多数时间里(以及之后),我监控着工作流——手动阅读输出以检查问题与 bug,并提示 Claude 编辑循环以修复问题。
你如何审查一个增加了 +100 万行的 PR?你如何开始建立负责任地合并大量 LLM 编写代码所需的信心?
一套与语言无关、带有百万级断言的测试套件、对抗式审查,以及当事情真的出错时,去修复生成代码的过程,而不是手改代码。
对抗式审查
对抗式审查要求 Claude(在单独的上下文窗口中)穷尽地想出变更会制造 bug 或不成立的理由。
拆分上下文窗口
通常对人来说,审查代码的人不是编写代码的人。写代码的人想合并代码,这可能使他们倾向于在尚未就绪时就发货。
Claude 也一样。写代码的 Claude 希望代码被接受。审查的 Claude 希望在代码中找到问题。
每位实现者配 1 个实现者、2 个或更多对抗式审查者。审查者的唯一工作:找出 bug 以及代码为何不成立的理由。实现者不审查。审查者不实现。
对抗式审查者实际抓到的三个 bug——每一条被引用的提交在主题行里都带着审查归属。三者都能编译;三者看起来都说得通。审查者是另一个 Claude,在自己的上下文窗口中:它只拿到 diff,没有别的——没有任何实现者的推理——并被要求找出错在哪里。代码从被引用的提交压缩而来;同样的 bug,同样的修复。
这看起来是什么样?
如果你即将做一件又大又贵的事,先降低风险能省时间与金钱。
准备工作
在写任何代码之前,我花了大约 3 小时与 Claude 讨论如何把我们 Zig 代码库中的模式紧密映射到 Rust。Claude 把这次讨论序列化成一份 PORTING.md 文档,最终上了 Hacker News。
下一个问题:你如何给手工管理内存的代码加上 Rust 生命周期?
于是我给 Claude 发了类似这样的提示:
我:让我们启动一个动态工作流,分析代码库中每个结构体字段的正确生命周期。这个工作流应读取每一个文件中的每一个结构体字段,并追踪控制流。首先,寻找难以在 Rust 中表达的复杂生命周期结构体字段,然后为该字段提出一个生命周期,再用 2 个对抗式审查智能体审查该生命周期,然后应用任何反馈,并序列化到一份 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 文件上循环该工作流,大约 2 分钟后,一个 Claude 在提交前运行了 git stash。另一个运行了 git stash pop。然后是 git reset HEAD --hard。它们在互相踩脚!而若我把每个 Claude 放进单独的 worktree(工作树),我会把磁盘空间用光,因为 Bun 的 git 仓库太大,而且最终变更需要一起编译、一起看。
于是,我让 Claude 编辑工作流,指示 Claude 从不运行 git stash 或 git reset,或任何不一次只提交特定文件的 git 命令。也不许用 cargo。任何慢命令都不许。
然后,Claude 恢复了工作流。它奏效了!但太慢,于是我把它拆成仅 4 个工作流分片,各自拥有自己的 worktree(共 4 个 worktree),每个分片运行 16 个 Claude 提交并推送文件。
终于开始写代码
多亏所有这些并行化与准备工作,峰值时 Claude 大约每分钟写出 1,300 行代码。每一行代码都由两个独立的对抗式审查者(也是 Claude)审查,并在提交前经历一轮修复。绝对还没有任何东西能工作。
图注:重写这 11 天内每小时的提交数量变化
移植分支上的每一次提交(排除合并),按小时分桶。峰值小时:695 次提交。
注意到时间不一致吗?我忘了提高这台 EC2 实例上的默认 IOPS。只要一条慢的 grep 命令,就足以让磁盘读写冻结数分钟。
把编译器错误当作工作队列
写完所有代码之后,我让 Claude 写一个工作流来修复每一个编译器错误。我们按 crate 逐个推进。
交互示意图:阶段 D 编译器错误工作队列(约 16,000 个错误)
阶段 D 如何工作,从其 1,610 次真实提交回放(5 月 6 日,UTC):cargo check 把约 16,000 个错误写入文件,按 crate 分组;工作流把它们分给 64 个 Claude——4 个 worktree 上 16 个循环,每个循环一个 Claude 修复、两个审查、一个应用。每一块筹码都是一批真实提交:它落到实际所属的 crate,然后计数器才移动。错误行是真实的提交主题。
其中最棘手的一类错误,就是循环依赖问题。
我们的 Zig 代码库是一个编译单元(实际上是一个 crate)。我想把新的 Rust 代码库拆成约 100 个 crate,以便 Rust 编译更快,但这需要避免循环依赖,同时相对原先 Zig 实现尽量少改。我在开始 Rust 重写前立刻做的 PR 不够用。我没有从头再来,而是又跑了一个工作流,对有循环依赖的代码应放何处进行分类并全部写下来——然后再用另一个工作流做重构。
修复循环依赖暴露出大约 16,000 个编译器错误。对 1 个人来说数量巨大,但对同时工作的 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 个 worktree 之一。对每个失败测试,把堆栈跟踪与错误保存到文件,1 个实现者提出修复,2 个对抗式审查者,然后 1 个修复者应用。
更多失败的开端
我们的测试套件有大量内存泄漏测试,以及少数可能超过一分钟的集成测试——例如:一个运行 next dev 并检查热模块重载能否 100 次感知到变更的测试。其中若干测试在 debug 构建中会超时。
我们还有耗尽机器上 TCP 套接字最大数量的压力测试、向磁盘读写数 GB 的测试,以及派生约 10k 个进程的测试。
这需要比「拜托」更强的隔离,于是我们用 systemd-run(cgroups)限制内存与 CPU 使用,并隔离 pid 命名空间。机器还是多次把磁盘空间用光并崩溃。
让测试套件在 CI 中通过
第一次 CI 运行两天后,失败列表从 972 个测试文件降到 23。又过了一天半,Linux 完全变绿——第一次感觉这次 Rust 重写真的会成功。
交互示意图:Buildkite 按平台竞速变绿
每一次 CI 构建的测试分片,按平台,覆盖跑过测试的 135 次构建(从 BuildKite 挖掘出 420 次)。亮绿:每个分片都通过。暗绿:无失败,但运行被截短(被取代)。红:至少一个分片失败。每条泳道在其完整套件首次通过时盖上时间戳——Linux 的 60 个分片几乎比 Windows 早一整天变绿。各平台一直在红色晃动,直到最后一批失败测试倒下;最终全绿构建是 #54202。
合并前剩余的时间很直接。一个工作流循环修复每个平台的 CI 测试失败,直到不再有测试失败。若干工作流用于 Windows 相关清理、去重代码、减少 unsafe 使用,以及一般性清理一些代码。
合并 Rust 重写
一旦 Bun 测试套件的 100% 在所有平台的 CI 上通过(并且我手动验证测试确实在运行、没有被跳过),我在本地跑了一堆命令来测试——然后按下了合并按钮。
合并进 main 并不是带版本号的发布。到这一步,我有足够信心继续推进并承诺这次重写,但还没有足够信心发布它。
统计
峰值时,我们同时运行 4 个这样的工作流,各自在单独的 worktree 中,每个工作流有 16 个 Claude。大约同时有 64 个 Claude。
11 天内的提交,按新增代码与删除着色
全部 6,502 次提交(排除合并)的回放。粉色条主要是新增代码;青色条主要是删除。行计数器计入一路上每一次重写——最终落地的 diff 是 +1,009,272。日志是真实的提交消息。
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 的预发布版本,一个 Mythos 级模型。Claude Code 的动态工作流让 64 个 Claude 连续跑了 11 天(否则我得自己写 harness 才能做到)。
工作仍在继续
自合并 Rust 移植以来,我们已完成 Claude Code Security 的 11 轮安全审查,并处理了发现的问题。
我们还为 Bun 中每一个解析器增加了 24/7 覆盖引导模糊测试——JavaScript、TypeScript、JSX、CSS、JSON5、JSONC、TOML、YAML、Markdown、INI、Bun Shell 脚本、semver 范围、.patch 文件,以及 CSS 颜色。模糊测试器自动把发现的 bug 发给 Claude 提交复现与修复的 PR,由人类审查 PR。到目前为止,它已对我们的解析器执行了 1000 亿次,带来了约 15 个 PR。
在撰写本文时,Bun 的 Rust 代码中约 4% 位于 unsafe 块内(约 13,000 个 unsafe 关键字,分布在约 27,000 行 / 约 780,000 行中),其中 78% 的块只有一行——一个来自 C++ 的指针,或一次对 C 库的调用。我预期这个数字会随时间下降,因为我们从忠实的 Zig 移植(没有可 grep 的 unsafe 关键字)重构为惯用 Rust,但我们将继续使用像 JavaScriptCore 这样的 C 与 C++ 库,因此它总会比纯 Rust 项目有更多 unsafe。
移植失误
Rust 重写的焦点是稳定性,但要交付如此巨大的变更却引入零回归是不可能的。
这次重写引入了 19 个已知回归,每一个都已被修复。
大多数回归来自在两种语言中语法相同但语义不同的代码。
debug_assert! 内的副作用
这两段片段看起来相似,但行为不同。Zig 的 assert 是函数,因此其参数在每种构建中都会运行。Rust 的 debug_assert! 是宏,因此在 release 构建中整个表达式会被抹掉,包括 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 把一个文件加入前端开发服务器的热重载图。在 release 构建中它停止运行,对使用 React 的 HTML 路由项目,在某个热重载文件被失效时,HMR 在某些情况下会坏掉:Cannot destructure property 'isLikelyComponentType' of 'k'。Debug 构建正常。#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 的 release 构建会保留它们。
Bun 的模块解析器把长文件名内联到一个全局列表,溢出时溢入溢出块。原先的 Zig 代码把每个块的大小设为 count / 4,或 2048。移植留下了一个占位符:
/// ... 因此先用非零占位,直到阶段 B 把
/// 每个实例化的值串通进来。
pub const BSS_OVERFLOW_BLOCK_SIZE: usize = 64;
这把上限从 840 万个内联文件名降到 270,272,真实项目会撞到,并让我们从 Zig 移植过来的 ptrs[4095] 差一错误变得可达。Rust 会 panic,而不是写越界。Zig 在这种情况下也会 panic——如果我们用 ReleaseSafe(我们只在 Windows 上用)。#31503
comptime 格式字符串
Output.pretty 把 <r> 与 <d> 颜色标记重写为 ANSI 转义。在 Zig 中,fmt 是 comptime,因此标记在参数被替换之前就消失了。Rust 函数没有 comptime 参数,因此 Output::pretty 只看到最终字符串,也会把标记重写到参数上。
// Zig 侧:
pub inline fn pretty(comptime fmt: string, args: anytype) void;
Output.pretty("<r>{f}<r>", .{hyperlink});
// Rust 侧:
pub fn pretty(payload: impl PrettyFmtInput);
Output::pretty(format_args!("<r>{}<r>", hyperlink));
bun update -i 把包名打印为 OSC 8 超链接,以 ESC \ 终止。那个反斜杠正好落在尾部 <r> 的 < 之前,标记解析器把它吃掉,于是 r 作为文本打印出来。
本应显示 oxfmt,而不是 oxfmtr
在 Rust 中它必须是宏:bun_core::pretty!("<r>{}<r>", hyperlink)。#30693
Bun 用 Rust 更好
到目前为止,Bun v1.4.0 修复了 128 个在 v1.3.14 中可复现的 bug。这些范围从内存泄漏到崩溃,再到帮助文本颜色错误。
降低内存占用
Rust 有强大的语言级工具来清理内存:Drop。当实现了 Drop 时,每当值离开作用域,drop 函数就会被自动调用。
impl Drop for Bytes {
fn drop(&mut self) {
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 符号表,存活时间超过了它们所属的构建。
// 在同一进程中把同一个 60 模块项目打包 2,000 次
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)
在那次最初缩减之后,团队用链接器优化(如 Identical Code Folding)、从 ICU 移除未使用数据,以及用 zstd 字典按需惰性解压 libicu 的小片段,探索了更多减小二进制体积的机会。
结合 Rust 重写、ICU 变更与 identical code folding,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 |
降低栈空间占用
TOML 解析器,以及 Bun 中所有其他递归下降解析器(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 function did not throw
Received value: {
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 复用栈空间槽位。这让带有嵌套作用域的大函数能显著少用栈空间。
此前,我们通过把特别大的函数重构成许多更小的函数,手工规避了一个未关闭的 issue。
快 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 轮平均)
| server | 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)
| workload | Bun v1.3.14 | Bun v1.4.0 | Δ |
|---|---|---|---|
| next build | 13.62 s | 13.03 s | +4.5% |
| vite build (tsc + vite) | 1.69 s | 1.65 s | +2.2% |
| tsc -b --force | 0.94 s | 0.89 s | +4.7% |
生产环境
Prisma 在 Bun 的 Rust 重写上发布了 Prisma Compute 公开 beta。
「我们碰到过内存泄漏,以及虚拟机暂停再恢复后无法恢复的连接池。当 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 代码的片段:
pub fn canMergeSymbols(
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) {
// 在 TypeScript 中,允许 import 与模块内符号静默碰撞。
// 大概是因为这些 import 可能是仅类型的:
//
// 示例:import {Foo} from 'bar'
// 示例:class Foo {}
//
if (existing == .import) {
return .replace_with_new;
}
// 省略
}
// 省略
}
pub fn can_merge_symbol_kinds<const IS_TYPESCRIPT_ENABLED: bool>(
scope_kind: Kind,
existing: symbol::Kind,
new: symbol::Kind,
) -> SymbolMergeResult {
if existing == symbol::Kind::Unbound {
return SymbolMergeResult::ReplaceWithNew;
}
if IS_TYPESCRIPT_ENABLED {
// 在 TypeScript 中,允许 import 与模块内符号静默碰撞。
// 大概是因为这些 import 可能是仅类型的:
//
// 示例:import {Foo} from 'bar'
// 示例:class Foo {}
//
if existing == symbol::Kind::Import {
return SymbolMergeResult::ReplaceWithNew;
}
// 省略
}
// 省略
}
任何理解原先 Zig 代码的人,都能理解机械翻译后的 Rust 代码。我审查原先的 Rust 重写 PR 的方式是:检查对抗式代码审查智能体是否正确抓住 Zig 代码与 Rust 代码之间的差异,是否确保移植指南与生命周期指南被遵守,并且我也亲自并排阅读了大量 Zig 与 Rust 代码。
接下来
Bun v1.4 让 Bun 更快、更小、占用更少内存,并给团队带来系统性地持续改进稳定性的强大工具:Rust 的借用检查器、Miri(在 CI 中对不断扩大的代码块运行)、LeakSanitizer,以及对解析器的 24/7 覆盖引导模糊测试。仍有更多要重构,但开局很好。
这次 Rust 重写本来需要一支对代码库有完整上下文的工程师团队一年工作。在 1 名工程师使用 Fable 并紧密监控 Claude Code 的情况下,我们从启动到测试套件在所有平台 100% 通过只用了 11 天。
今天,一名工程师能做到的事比一年前多得多。