用 Rust 重写 Bun
2026-07-08 · Jarred Sumner
摘要: Bun 用 11 天完成 Zig 到 Rust 全量重写,Claude Code 主导,测试全绿实现零跳过零删除。Zig 手动内存管理与 JS 引擎 GC 在同一个运行时中混用,催生了 13 类释放后使用和内存泄漏------Rust 的 borrow checker 从语言层面根除这类崩溃。文章复盘这场 AI 驱动的重写决策、Anthropic 收购背景和稳定性量化收益。
本文译自 Jarred Sumner 的文章 Rewriting Bun in Rust。
以下是原文翻译:
声明:Bun 于 2025 年 12 月被 Anthropic 收购。我和 Bun 团队的其他成员现在都在 Anthropic 工作。在这次的 Rust 重写过程中,我用了 Claude Fable 5 的预发布版本。
Bun 最近完成了一次从 Zig 到 Rust 的大规模重写,整个过程只用了 11 天。
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 客户端
- Node.js API 实现,如
fs、net、tls以及几十个其他模块
Bun 的第一个版本是我在奥克兰一间狭窄的公寓里,用 Zig 语言,没有 LLM 辅助,花了一年写出来的。像 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(),导致释放后使用崩溃- 在
node:http2中,重入的 JS 回调(如超时监听器、options 获取器或写入回调内的session.request())触发哈希表重哈希,内部流指针失效,引发释放后使用崩溃- 在
UDPSocket.send()和sendMany()中,valueOf()或toString()回调内的用户代码在捕获载荷与实际发送之间分离了ArrayBuffer,导致释放后使用- 在
Buffer#copy和Buffer#fill中,valueOf回调在参数强制转换期间分离或调整了底层ArrayBuffer的大小,导致崩溃和越界读取- 在
UDPSocket.sendMany()中,用户 JS 回调在迭代中途改变了 socket 的连接状态,导致堆越界写入- 在
crypto.scrypt中,输出缓冲区分配失败时,回调和受保护的密码/盐值缓冲区从未释放,导致内存泄漏SSLWrapper.init在错误路径上泄漏了 strdup 复制的密码短语- 在
tlsSocket.setSession()中,每次调用在d2i_SSL_SESSION之后缺失了对应的SSL_SESSION_free,每次泄漏一个SSL_SESSION(约 6.5 KB)fs.watch()的监听器在调用.close()后从未被垃圾回收,原因是引用计数下溢导致每个监听器被永久固定为 GC 根节点- 在 CSS 解析器中,
background-clip含有厂商前缀和多层背景时,导致双重释放崩溃DuplexUpgradeContext从未释放------每次tls.connect({ socket: duplex })完整泄漏一个- 在
MessageEvent中,BroadcastChannel或MessagePort并发访问时,GC 标记线程可能观察到m_data中出现撕裂的变体,导致竞态条件崩溃
我们本可以一直这样逐个修 bug,但我们对信赖我们的用户有责任做得更好------从系统层面预防这类问题的再次发生。
我们已经做的事情
- 修补了 Zig 编译器,加入 Address Sanitizer 支持。每次提交都用 ASAN 跑测试套件。
- 在 Windows 上发布 Zig 安全检测的 ReleaseSafe 版本
- 用 Fuzzilli(V8 和 JavaScriptCore 用的 JavaScript 引擎模糊测试工具)全天候对 Bun 的运行时 API 进行模糊测试
- 大量端到端的内存泄漏测试
这些措施已经比很多项目做得更多了。
只要足够聪明,不犯错误就行?
那份 bug 修复清单让人沮丧,每晚睡前都在担心 Bun 的崩溃,我厌倦了这种感觉。我不怪 Zig------其他 Zig 用户没有我们这么多 bug,把垃圾回收与手动管理的内存混用,对软件来说足够罕见,没有哪种语言会专门为此设计。没有 Zig 我们走不到今天,我永远感激它。直到不久前,对 Bun 这样的项目来说,编程语言的选择还是一条单行道。
JavaScript 是门垃圾回收语言,JavaScriptCore(以及 V8)这类现代引擎对异常处理和垃圾回收器有严格的规则。Zig 和 C 一样,不替你管理内存------对很多项目来说,这种权衡正是选 Zig 的好理由。Zig 没有构造函数/析构函数,大多数清理工作需要在每个调用点用 defer 显式写出。
对 Bun 来说,正确处理垃圾回收值和手动管理值的生命周期,一直是稳定性问题的主要来源------最常见的是小内存泄漏,偶尔会引发崩溃。每次内存分配都需要仔细审查:这些字节在哪释放?怎么确保只释放一次?JavaScript 异常检查对了没有?这个指向垃圾回收内存的指针对保守栈扫描器可见吗?这到底是垃圾回收内存还是手动管理的内存?
对于稳定性问题,越早发现越好。模糊测试在代码合并后进行。CI 在代码推送时触发。运行时安全检查和地址消毒器在代码运行时生效(希望是在开发阶段,CI 之前就能跑起来)。
减少这类问题的一个常用方法,是确保需要清理的代码始终且只执行一次。Zig 被设计为简单语言,没有隐藏的控制流,所以它用显式的 defer 关键字在作用域结束时执行代码,而不是像 C++ 那样靠隐式的析构函数或 Rust 的隐式 Drop。
| 语言 | 清理机制 |
|---|---|
| Zig | defer, errdefer |
| C++ | ~Destructor(析构函数), &&Move(移动语义) |
| Rust | Drop |
对于 Zig 代码,到底应该在什么时候执行清理代码?如果把同一个 *T 传给多个不同的函数,怎么知道它何时不再被访问、可以清理?当某些函数在调用后仍需继续引用这段内存时,又该怎么办?我们当前的方案是几种策略的组合:
- arena 生命周期,即内存可访问的作用域是清晰的(解析器状态不会逃逸出调用函数,AST 节点在这里是个好选择)
- 引用计数
- 高度警惕
许多项目选择用风格指南来回答这类问题。TigerBeetle 的 TigerStyle(Zig 语言)是一个例子,Google 那篇 3.1 万字的 C++ 风格指南是另一个。风格指南的难点在于执行。怎么确保风格指南被遵守?过去只能靠代码审查,配合 linter 和静态分析器尽力而为。
对 Bun 来说,一个可行的选择是制定严格的风格指南,明确所有权期望,在类型系统中显式声明。由于 Zig 没有运算符重载,代码可能变成这样:
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 那样顺手:
zig
fn foo(a: *TCPSocket) !void {
const b = try do_something_with_a(a);
// ...
}
那 C/C++ 呢?
Bun 大约 20% 的代码是 C++ 写的,而且嵌入了好几个 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 写的,跟运行时的编程语言无关。
一整年对用户零影响------这不现实。所以通过代码风格来强制修复稳定性问题,是我们当时的最佳方案。这也是我们在 Bun 代码库中加入受 Rust 启发的智能指针时的计划。
但说实话,我不想那么做。自制的智能指针跟 Rust 比,体验更差,还没有任何安全保障。
反过来想------如果我花一周时间,试试能不能用 Anthropic 的新模型把 Bun 重写成 Rust 呢?
一开始我没指望能成。几天后,测试套件通过的比例越来越高,新生成的 Rust 代码跟原来的 Zig 代码高度吻合。我的看法从"值得一试"变成了"我要合并它"。
Claude,把 Bun 重写成 Rust
搞砸这件事的方式太多了。比如对 Claude 说一句"把 Bun 重写成 Rust,别犯任何错误",然后祈祷它能成功------我没这么干。
想想一个真人会怎么做。第一个大问题是:
增量重写?还是一次性全重写?
根据我早期把 esbuild 的 transpiler 从 Go 移植到 Zig 的经验(没有 LLM),一次性全重写效果更好。增量重写会产生大量临时代码------你希望它们最终被删掉,但在中短期内会很痛苦。
第二个大问题:具体怎么做?
怎么让 Rust 版的 Bun 跟原来保持一致------同样的架构、性能和功能集------同时还能享受 Rust 的 borrow checker 这类特性?怎么保证团队在重写之后还能继续维护?
答案是:先做一次看起来像"把 Zig 代码编译成 Rust"的机械重写。等 Bun v1.4 发布后,再逐步重构,减少 unsafe 的使用,让代码更地道 Rust。
只有这两个大问题。其他都是战术细节。
能写代码也能审代码的循环
软件工程师的日常工作,可以简化为一种循环:
ini
// 伪代码,非真实代码
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 这些变更,检查回归问题和正确性。然后你处理反馈。
我花了 11 天,用 Claude Code 连续跑了大约 50 个动态工作流,把 Bun 用 Rust 重写了。
每个动态工作流都是这样一个循环------任务包括:
- 生成移植指南,把 Zig 的模式和类型映射到 Rust
- 把每个
.zig文件机械地移植为.rs文件,遵循 PORTING.md 和 LIFETIMES.tsv - 修复每个 crate 的编译错误
- 让
bun test或bun build等子命令正常工作 - 让 Bun 整个测试套件中的每个测试都通过
- 若干大型重构和清理工作
那 11 天里(以及之后的大部分时间),我都在监控工作流------手动阅读输出,检查问题和 bug,提示 Claude 修改循环来修复问题。
一个 PR 增加了 100 多万行代码,你怎么审查?怎么建立起足够的信心,去负责任地合并大量 LLM 编写的代码?
答案是:一个包含百万级断言的、与语言无关的测试套件,对抗性代码审查,以及------出问题时------修复生成代码的流程本身,而不是手动修补代码。
对抗式审查
对抗式审查要求 Claude(在独立的上下文窗口中)穷举出所有变更可能引入 bug 或无法正常运行的理由。
分离的上下文窗口
人类场景下,审查代码的人不是写代码的人。写代码的人想合并代码,这种倾向会让他们在代码还没就绪时就急于发布。
Claude 也一样。写代码的 Claude 希望代码被接受,审查的 Claude 则希望找出问题。
每位实现者配 1 位和 2 位或更多对抗式审查者。审查者唯一的事就是找 bug 和代码不对的地方。实现者不审查,审查者不实现。
Bug 1 of 3 · 异步关闭
实现者(src/runtime/api/bun/js_bun_spawn_bindings.rs · 编译通过):
rustfor stdio in [spawned_stdout, spawned_stderr] { match stdio { StdioResult::Buffer(mut pipe) => { // pipe: Box<uv::Pipe> --- 交给 libuv 去关闭 pipe.close(Subprocess::on_pipe_close) } StdioResult::Fd(fd) => fd.close(), StdioResult::Unavailable => {} } }对抗式审查者 :uv_close 是异步的------libuv 会保留原始句柄指针直到下一个事件循环 tick,然后调用 on_pipe_close 释放内存。但
pipe是一个 Box,在这个 match 分支末尾就会被释放------libuv 此时拿到的是一块已释放的内存,而关闭回调又会第二次释放它。先是用后释放,然后是双重释放。修复 :
Box::leak(pipe).close(Subprocess::on_pipe_close)
Bug 2 of 3 · 负 timespec实现者(src/runtime/node/node_fs.rs · 编译通过):
rust// 将 f64 秒拆分为 timespec 格式的 {sec, nsec} let sec = t.trunc(); TimeLike { sec: sec as i64, nsec: ((t - sec) * 1e9) as i64, }对抗式审查者:对于负的、非整数的时间(比如 1970 年之前的文件修改时间),trunc 朝零舍入:-1.5 变成了 {sec: -1, nsec: -500_000_000}。负的 nsec 是无效的 timespec。用 floor 则能把 nsec 保持在 [0, 1e9) 范围内:{sec: -2, nsec: 500_000_000}。
修复 :
let sec = t.floor(); nsec: ((t - sec) * 1e9).round() as i64
Bug 3 of 3 · 贪婪的 unwrap_or实现者(src/css/values/color.rs · 编译通过):
rust// color-mix() 的每一侧都可以省略百分比; // 缺失的一侧默认取另一侧的剩余值 let p1 = first.percentage.unwrap_or(1.0 - second.percentage.unwrap());对抗式审查者:unwrap_or 会立即求值其参数------即使 first.percentage 是 Some,second.percentage.unwrap() 仍然会执行。所以 color-mix(in srgb, red 40%, blue) 这种只有第二侧缺省百分比的情况,在 unwrap_or 有机会忽略它之前,参数表达式内部就已经 panic 了。unwrap_or_else 接受闭包,保持惰性求值。
修复 :
let p1 = first.percentage.unwrap_or_else(|| 1.0 - second.percentage.unwrap());
这是对抗式审查者实际抓到的三个 bug------每条相关的提交都在主题行标注了审查归属。三个 bug 都能编译通过,看上去也都合理。审查者是另一个拥有独立上下文窗口的 Claude:它只拿到 diff,没有实现者的任何推理过程,任务就是找出哪里有问题。
实际效果如何?
要做一件大而贵的事情,先降低风险能省时间和钱。
准备工作
写代码之前,我花了大约 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 个。每个文件,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 放到独立的工作树里,磁盘空间又不够,因为 Bun 的 git 仓库太大,最终所有改动还要放在一起编译验证。
于是我让 Claude 修改工作流,明确指示永远不要运行 git stash 或 git reset,也不要用任何一次性提交多个文件的 git 命令。同样禁止 cargo。慢命令一律不许跑。
之后 Claude 恢复了工作流。这次跑起来了!但还是太慢,于是我把它拆成 4 个工作流分片,每个有独立的工作树(共 4 个),每个工作树里 16 个 Claude 并发提交和推送文件。
终于开始写代码
得益于所有这些并行化和准备,Claude 在峰值时每分钟写了约 1,300 行代码。每一行代码都经过两个独立的对抗式审查者(也是 Claude)审查,提交前经历一轮修复。当然,这些代码还没一个能跑起来。
11 天 × 24 小时 · PDT
6,502 次提交 --- 每小时 1--695 次提交
移植分支上的每次提交(合并提交除外),按小时分桶。峰值小时:695 次提交。
注意到提交时间不均匀了吗?因为我忘了提高运行它的 EC2 实例的默认 IOPS。一个慢 grep 命令就能让磁盘读写冻结好几分钟。
把编译器错误当工作队列
写完所有代码后,我让 Claude 写一个新的工作流,逐个修复所有编译器错误。我们按 crate 逐个处理。
剩余约 16,000 个错误 --- 5 月 6 日周三,凌晨 12:40 PDT
阶段 D:64 个 Claude 分布在 4 个工作树上,每个循环:1 人修复 → 2 人审查 → 1 人应用
阶段 D 的工作方式:
cargo check把约 16,000 个错误写入文件,按 crate 分组;工作流把它们分配给 64 个 Claude------4 个工作树各跑 16 个循环,每个循环里一个 Claude 修复、两个审查、一个应用。
最棘手的错误类别是循环依赖。
我们的 Zig 代码库是一个编译单元(相当于一个 crate)。我想把新的 Rust 代码库拆成约 100 个 crate,编译更快,但必须避免循环依赖,同时尽量减小与原始 Zig 的差异。我为此提前准备的 PR 还不够完善。我没有从头开始,而是又跑了一个工作流来分析存在循环依赖的代码应该放哪,记录所有结论------接着再跑一个工作流来完成重构。
修复循环依赖后暴露了约 16,000 个编译器错误。对一个人来说这个数字很大,但对 64 个同时工作的 Claude 来说也不是不能处理。
为了最大化并行度,工作流逐个 crate 循环执行。
- 对每个 crate,运行
cargo check,按文件分组输出,把错误保存到文件 - 修复该 crate 内的所有编译器错误
- 2 名对抗式审查者审查改动
- 1 名修复者应用修复
为了防止多个 Claude 互相踩踏,cargo check 只在最开始时运行,和其他运行一样,全程不碰 git 直到结束。
又一段弯路
Claude 把"让所有 crate 都能编译"理解成了"把有编译错误的函数先 stubbing 掉"。Claude 还开始写长得可疑的解释性注释为各种权宜方案辩解,于是我加了一条规则让对抗式审查者直接驳回:
如果你需要一大段注释来解释为什么这个权宜之计没问题,那代码本身就是错的------去修代码。
改了一版提示词,几小时后,这些问题不再出现。
Smoke tests
模型们总爱说"烟雾测试"。
cargo check 通过后,下一步是让它能编译并运行 bun --version。过程中遇到了链接器错误。然后程序一启动就崩溃。
下一个目标是让它能跑 bun test <file>。搞定这个,我们就能开始跑测试了。上另一个工作流,循环处理 Bun CLI 子命令:
- 把每个失败的堆栈跟踪连同对应的子命令保存到文件
- 按子命令分组,每组由 1 个 Claude 负责修复
- 2 个对抗式审查者
- 1 个修复者应用建议
让测试套件在本地通过
这个工作流循环处理测试文件。
从代码库中按文件夹分片到 4 个工作树之一,跑大约 100 个随机测试文件。每个失败的测试,把堆栈跟踪和错误保存到文件,1 个实现者提出修复方案,2 个对抗式审查者审查,然后 1 个修复者应用修改。
更多的弯路
测试套件有很多内存泄漏测试和少数需要一分多钟的集成测试------比如一个跑 next dev 并检查热模块重载能 100 次抓到变更的测试。其中几个在 debug 构建中会超时。
还有耗尽机器最大 TCP 套接字数量的压力测试,有读写数 GB 数据的测试,以及衍生约一万个进程的测试。
这些需要的隔离强度远超说声"请"就能搞定,所以我们用了 systemd-run(cgroups)来限制内存和 CPU 使用,隔离 pid 命名空间。即便如此,机器还是因为磁盘空间耗尽崩溃了好几次。
让测试套件在 CI 中通过
首次 CI 运行的两天后,失败列表从 972 个测试文件降到了 23 个。又过了一天半,Linux 全线变绿------那一刻,我第一次觉得这次 Rust 重写是真的能成。
6 / 6 个平台全部变绿 --- 构建 #54202 · PDT 5 月 14 日周四凌晨 12:23
按平台展示的每次 CI 构建的分片测试结果,跨越 135 个执行过测试的构建(从 BuildKite 挖出 420 个数据点)。每条跑道标记了其完整测试套件首次通过的时间------Linux 的 60 个分片比 Windows 早将近一整天全部变绿。
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 定价约 16.5 万美元。如果靠人工,3 名对代码库有完整上下文理解的工程师得干一年,那一年里我们没法改进 Node.js 兼容性、修 bug、修安全问题或做新功能。我们不会那样做。现实的替代方案是什么也不做,然后永远修文章开头那些 bug。
这就是当下技术的前沿。我用的是 Claude Fable 5 的预发布版本,一款 Mythos 级别的模型。Claude Code 的动态工作流让 64 个 Claude 持续跑了 11 天(否则我得自己写 harness 才能实现)。
工作还在继续
合并 Rust 移植以来,我们完成了来自 Claude Code Security 的 11 轮安全审查,处理了所有发现的问题。
我们还为 Bun 里的每个解析器加了全天候的覆盖率引导模糊测试------包括 JavaScript、TypeScript、JSX、CSS、JSON5、JSONC、TOML、YAML、Markdown、INI、Bun Shell 脚本、semver 范围、.patch 文件和 CSS 颜色值。模糊测试器自动把发现的问题发给 Claude,由其提交包含复现步骤和修复方案的 PR,然后人工审核。到目前,它对解析器执行了 1000 亿次测试,产生了约 15 个 PR。
现在 Bun 约 4% 的 Rust 代码在 unsafe 块里(在约 78 万行代码中,约 1.3 万个 unsafe 关键字散布在约 2.7 万行内),其中 78% 的块只有一行------要么是一个来自 C++ 的指针,要么是对某个 C 库的一次调用。我预计随着我们从忠实的 Zig 移植版(其中没有可 grep 到的 unsafe 关键字)逐步重构为地道的 Rust,这个比例会下降。但我们会继续用 JavaScriptCore 这类 C/C++ 库,所以 unsafe 数量终究会比纯 Rust 项目多。
移植中的失误
这次 Rust 重写的核心目标是稳定性,但要做这么大改动还零回归,几乎不可能。
这次重写引入了 19 个已知回归,每个都已被修复。
大多数回归的根源在于:两段代码在语法上一模一样,但语义截然不同。
debug_assert! 内部的副作用
下面两段代码看起来像,行为却不一样。Zig 的 assert 是一个函数,它的参数每次构建都会执行。Rust 的 debug_assert! 是一个宏,发布构建中整个表达式会被擦除,包括 insert_stale 调用。
zig
// 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 负责把文件添加到前端开发服务器的热重载图中。发布构建里这段代码不再执行,导致特定场景下 HMR 失效------那些使用 React 且包含 HTML 路由的项目,热重载文件被失效时报错:Cannot destructure property 'isLikelyComponentType' of 'k'。debug 构建一切正常。#30678
奇数长度的切片
Bun 的 Zig 辅助函数 reinterpretSlice(u16, bytes)(在支持切片的内置类型转换之前就已存在)用 @divTrunc 转换,忽略末尾的奇数个字节。bytemuck::cast_slice 遇到这种情况会直接 panic。Blob.text() 处理以 UTF-16 BOM 开头、后面跟奇数个字节的数据时,不再返回字符串,而是导致进程 panic。我们回到了忽略奇数个字节的做法:&buf[..buf.len() & !1]。#31188
边界检查
在 macOS 和 Linux 上,我们用 ReleaseFast 编译 Bun 的 Zig 代码,去除边界检查。Rust 的发布构建保留边界检查。
Bun 的模块解析器把长文件名内部化到一个全局列表里,列表不够时溢出到溢出块。原始 Zig 代码把每个溢出块的大小设为 count / 4,也就是 2048。移植代码里留了一个占位符:
rust
/// ......所以先用一个非零的临时值,等 Phase B 把
/// 每次实例化的值传过来再改。
pub const BSS_OVERFLOW_BLOCK_SIZE: usize = 64;
内部化文件名的上限从 840 万降到了 270,272------真实项目确实会达到这个上限。同时,从 Zig 移植过来的一个 ptrs[4095] 越界访问也变得可达。Rust 越界时直接 panic,不像 Zig 那样写入越界地址。不过 Zig 如果用 ReleaseSafe 模式(我们只在 Windows 上用)也会 panic。#31503
comptime 格式化字符串
Output.pretty 把 <r> 和 <d> 颜色标记重写为 ANSI 转义序列。Zig 里 fmt 是编译期参数,颜色标记在参数被替换之前就处理完了。Rust 函数没有编译期参数,Output::pretty 只能看到完整的格式化字符串,导致颜色标记也被应用到参数上。
zig
// 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 被当作文本打印出来。

Rust 里这必须是个宏:bun_core::pretty!("<r>{}<r>", hyperlink)。#30693
Rust 让 Bun 更好
Bun v1.4.0 修复了 v1.3.14 中可复现的 128 个 bug,从内存泄漏到崩溃到文本颜色显示错误,各种都有。
内存占用降低
Rust 有个强大的语言级内存清理工具:Drop。实现了 Drop 后,drop 函数会在值离开作用域时自动调用。
rust
impl Drop for Bytes {
fn drop(&mut self) {
if !self.pinned.is_empty() {
JSC__JSValue__unpinArrayBuffer(self.pinned);
}
}
}
Zig 里可以用 defer 在作用域结束时执行代码:
zig
const bytes: ArrayBuffer = try .fromPinned(global, value);
defer bytes.unpin();
Zig 的 defer 需要在每个可能需要清理的调用点手动添加。很容易忘记清理(内存泄漏),或者在极少触达的错误处理代码里执行两次清理(双重释放)。Rust 的 Drop 在值不再可访问时自动运行------用"有隐藏控制流"换来了防止常见陷阱。
Drop 修复了 Bun 中几个和错误处理中的文件路径相关的内存泄漏。
每个可检测的内存泄漏都修了
我们改进了 Bun 的 LeakSanitizer 集成,追踪所有原生代码的内存分配。
举个例子:每次进程内的 Bun.build() 调用泄漏了几 MB 内存------解析后的源代码文本和 AST 符号表超出了它们所属的构建生命周期。
js
// 在同一个进程中把同一个 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。
初始缩小之后,团队用链接器优化(如 Identical Code Folding)、移除 ICU 中未使用的数据、以及用 zstd 字典按需延迟解压 libicu 中的小部分内容,进一步缩小了体积。
结合 Rust 重写、ICU 改动和 Identical Code Folding,Linux 和 Windows 上 Bun 的二进制体积缩小了约 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 重写之前导致了一些测试失败:
css
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.lifetime.start 和 llvm.lifetime.end 内部函数,让 LLVM 可以重用栈空间槽位。这样,包含嵌套作用域的大型函数能显著减少栈空间使用。
之前我们通过把特大函数拆成多个小函数,手动绕开了一个已知问题。
快 2% 到 5%
Rust 支持 C/C++ 和 Rust 之间的跨语言链接时优化,可以实现跨语言内联。
我们在 Linux x64(EC2,Xeon Platinum 8488C)上对 Bun v1.3.14 和 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 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 公测。
"我们遇到了内存泄漏和连接池在 VM 暂停恢复后无法恢复的问题。Rust 重写版出来后,我们用同样的故障模式测试了它------处理得很完美。"------ Alexey Orlenko
Claude Code v2.1.181(6 月 17 日发布)及更高版本用了 Bun 的 Rust 移植版。Linux 上启动速度快了 10%,但除此之外几乎没人注意到。无聊也是好事。

发布
Bun v1.3.14 是用 Zig 写的最后一个版本。Bun v1.4.0 将是用 Rust 写的第一个版本。目前在 canary 频道可用------如有问题请报告:
bash
bun upgrade --canary
可维护性
对我和团队来说,新的 Rust 代码库和旧的 Zig 代码库感觉很像。下面是原始 Zig 代码和新的 Rust 代码的对比:
zig
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 {Foo} from 'bar'
// class Foo {}
//
if (existing == .import) {
return .replace_with_new;
}
// ...
}
// ...
}
rust
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 {Foo} from 'bar'
// class Foo {}
//
if existing == symbol::Kind::Import {
return SymbolMergeResult::ReplaceWithNew;
}
// ...
}
// ...
}
能看懂原始 Zig 代码的人,也能看懂机械翻译后的 Rust 代码。我审查了最初的 Rust 重写 PR,检查对抗式代码审查智能体是否正确捕捉了 Zig 和 Rust 之间的差异,确保它们遵守了移植指南和生命周期指南,同时自己也逐行对比阅读了大量代码。
下一步
Bun v1.4 让 Bun 更快、更小、内存占用更低,还给团队提供了系统性持续提升稳定性的工具:Rust 的 borrow checker、Miri(已在 CI 中对越来越多代码运行)、LeakSanitizer,以及针对解析器的 24/7 覆盖率引导模糊测试。还有更多待重构的内容,但一切已经有了好的开端。
这次 Rust 重写,由一个熟悉代码库的工程师团队来做,需要一年。而一位工程师,配合 Fable 并密切监控 Claude Code,11 天内从零做到了全平台 100% 测试套件通过。
今天,一位工程师能做的事情,比一年前多得多。
📖 更多内容请访问 原文链接
往期精选