【翻译】用 Rust 重写 Bun

0 阅读31分钟

用 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 客户端
  • 诸如 fsnettls 以及数十个其他模块的 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#copyBuffer#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 标记线程可能在来自 BroadcastChannelMessagePort 的并发访问期间观察到 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

语言清理方式
Zigdefererrdefer
C++~Destructor,&&Move
RustDrop

对 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 testbun build 等子命令能工作
  • 让 Bun 整个测试套件中的每一项测试都通过
  • 若干大规模重构与清理遍次

在那 11 天的大多数时间里(以及之后),我监控着工作流——手动阅读输出以检查问题与 bug,并提示 Claude 编辑循环以修复问题。

你如何审查一个增加了 +100 万行的 PR?你如何开始建立负责任地合并大量 LLM 编写代码所需的信心?

一套与语言无关、带有百万级断言的测试套件、对抗式审查,以及当事情真的出错时,去修复生成代码的过程,而不是手改代码。

对抗式审查

对抗式审查要求 Claude(在单独的上下文窗口中)穷尽地想出变更会制造 bug 或不成立的理由。

拆分上下文窗口

通常对人来说,审查代码的人不是编写代码的人。写代码的人想合并代码,这可能使他们倾向于在尚未就绪时就发货。

Claude 也一样。写代码的 Claude 希望代码被接受。审查的 Claude 希望在代码中找到问题。

每位实现者配 1 个实现者、2 个或更多对抗式审查者。审查者的唯一工作:找出 bug 以及代码为何不成立的理由。实现者不审查。审查者不实现。

交互示意图:对抗式审查在合并前抓出 bug

对抗式审查者实际抓到的三个 bug——每一条被引用的提交在主题行里都带着审查归属。三者都能编译;三者看起来都说得通。审查者是另一个 Claude,在自己的上下文窗口中:它只拿到 diff,没有别的——没有任何实现者的推理——并被要求找出错在哪里。代码从被引用的提交压缩而来;同样的 bug,同样的修复。

这看起来是什么样?

如果你即将做一件又大又贵的事,先降低风险能省时间与金钱。

准备工作

在写任何代码之前,我花了大约 3 小时与 Claude 讨论如何把我们 Zig 代码库中的模式紧密映射到 Rust。Claude 把这次讨论序列化成一份 PORTING.md 文档,最终上了 Hacker News

下一个问题:你如何给手工管理内存的代码加上 Rust 生命周期?

于是我给 Claude 发了类似这样的提示:

我:让我们启动一个动态工作流,分析代码库中每个结构体字段的正确生命周期。这个工作流应读取每一个文件中的每一个结构体字段,并追踪控制流。首先,寻找难以在 Rust 中表达的复杂生命周期结构体字段,然后为该字段提出一个生命周期,再用 2 个对抗式审查智能体审查该生命周期,然后应用任何反馈,并序列化到一份 LIFETIMES.tsv,供其他 Claude 查阅。

然后对 PORTING.mdLIFETIMES.tsv 一起再做一轮对抗式审查,以修复任何冲突建议并复查一切。我也亲自通读了一遍。

试运行

在要求 Claude 把全部 1,448 个 .zig 文件翻译成 .rs 文件之前,我先从 3 个开始。对这 3 个文件中的每一个,1 个实现者写出新的 .rs 文件,2 个对抗式审查者检查 .rs 文件是否匹配 .zig 文件的行为,以及是否遵循 PORTING.mdLIFETIMES.tsv。之后,1 个修复者应用任何建议。

失败的开端

我让 Claude 在全部 1,448 个 .zig 文件上循环该工作流,大约 2 分钟后,一个 Claude 在提交前运行了 git stash。另一个运行了 git stash pop。然后是 git reset HEAD --hard。它们在互相踩脚!而若我把每个 Claude 放进单独的 worktree(工作树),我会把磁盘空间用光,因为 Bun 的 git 仓库太大,而且最终变更需要一起编译、一起看。

于是,我让 Claude 编辑工作流,指示 Claude 从不运行 git stashgit reset,或任何不一次只提交特定文件的 git 命令。也不许用 cargo。任何慢命令都不许。

然后,Claude 恢复了工作流。它奏效了!但太慢,于是我把它拆成仅 4 个工作流分片,各自拥有自己的 worktree(共 4 个 worktree),每个分片运行 16 个 Claude 提交并推送文件。

终于开始写代码

多亏所有这些并行化与准备工作,峰值时 Claude 大约每分钟写出 1,300 行代码。每一行代码都由两个独立的对抗式审查者(也是 Claude)审查,并在提交前经历一轮修复。绝对还没有任何东西能工作。

重写 11 天内每小时的提交数

图注:重写这 11 天内每小时的提交数量变化

交互示意图(原站)

移植分支上的每一次提交(排除合并),按小时分桶。峰值小时:695 次提交。

注意到时间不一致吗?我忘了提高这台 EC2 实例上的默认 IOPS。只要一条慢的 grep 命令,就足以让磁盘读写冻结数分钟。

把编译器错误当作工作队列

写完所有代码之后,我让 Claude 写一个工作流来修复每一个编译器错误。我们按 crate 逐个推进。

交互示意图:阶段 D 编译器错误工作队列(约 16,000 个错误)

交互示意图:阶段 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 按平台竞速变绿

交互示意图:Buildkite 按平台竞速变绿

交互示意图(原站)

每一次 CI 构建的测试分片,按平台,覆盖跑过测试的 135 次构建(从 BuildKite 挖掘出 420 次)。亮绿:每个分片都通过。暗绿:无失败,但运行被截短(被取代)。红:至少一个分片失败。每条泳道在其完整套件首次通过时盖上时间戳——Linux 的 60 个分片几乎比 Windows 早一整天变绿。各平台一直在红色晃动,直到最后一批失败测试倒下;最终全绿构建是 #54202。

合并前剩余的时间很直接。一个工作流循环修复每个平台的 CI 测试失败,直到不再有测试失败。若干工作流用于 Windows 相关清理、去重代码、减少 unsafe 使用,以及一般性清理一些代码。

合并 Rust 重写

一旦 Bun 测试套件的 100% 在所有平台的 CI 上通过(并且我手动验证测试确实在运行、没有被跳过),我在本地跑了一堆命令来测试——然后按下了合并按钮。

合并进 main 并不是带版本号的发布。到这一步,我有足够信心继续推进并承诺这次重写,但还没有足够信心发布它。

统计

峰值时,我们同时运行 4 个这样的工作流,各自在单独的 worktree 中,每个工作流有 16 个 Claude。大约同时有 64 个 Claude。

11 天内的提交,按新增代码与删除着色

11 天内的提交,按新增代码与删除着色

交互示意图(原站)

全部 6,502 次提交(排除合并)的回放。粉色条主要是新增代码;青色条主要是删除。行计数器计入一路上每一次重写——最终落地的 diff 是 +1,009,272。日志是真实的提交消息。

0 个测试被跳过或删除

11 天(5 月 3 日 → 合并于 5 月 14 日)· 6,778 次提交

平台expect() 调用测试文件
Debian 13 x641,386,82660,6244,174
macOS 14 arm641,259,95358,8504,175
Windows 2019 x641,007,54457,3374,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 中,fmtcomptime,因此标记在参数被替换之前就消失了。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

本应显示 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.14Bun v1.4.0
5001,914 MB526 MB
1,0003,506 MB586 MB
1,5005,097 MB608 MB
2,0006,745 MB609 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)Windows76 MB
Bun v1.3.14Windows94 MB
Bun v1.4.0 (canary)Linux70 MB
Bun v1.3.14Linux88 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.startllvm.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 轮平均)

serverBun v1.3.14Bun v1.4.0Δ
Bun.serve169.6k177.7k+4.8%
node:http103.8k108.5k+4.5%
Elysia158.9k163.3k+2.8%
express64.5k66.6k+3.2%
fastify91.5k95.9k+4.8%

应用与 CLI 工作负载(hyperfine)

workloadBun v1.3.14Bun v1.4.0Δ
next build13.62 s13.03 s+4.5%
vite build (tsc + vite)1.69 s1.65 s+2.2%
tsc -b --force0.94 s0.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 天。

今天,一名工程师能做到的事比一年前多得多。