一眼看懂
Bun v1.3.14 是最后一个 Zig 版本,Bun v1.4.0 将是第一个 Rust 版本。迁移不是为了重写产品形态,而是为了保留 Bun 的架构、性能和特性,同时利用 Rust 的所有权、借用检查、Drop 和工具链,系统性降低内存安全与生命周期错误。
Bun 在 2025 年 12 月被 Anthropic 收购,Jarred Sumner 和 Bun 团队成员在 Anthropic 工作。作者表示,Rust 改写大量使用了预发布版 Claude Fable 5。
这篇文章的主线很清楚:Bun 的范围太大,靠人工逐个修补内存问题会长期消耗稳定性;传统完整重写又通常不可接受;但借助 LLM 并行工作流、对抗式代码审查和已有的语言无关测试套件,团队把一次本来需要小团队一年左右的迁移压缩到了 11 天。
为什么要改写
Bun 最初是 esbuild 的 JavaScript 与 TypeScript 转译器从 Go 到 Zig 的逐行移植。作者第一次写 Zig 是 2021 年 4 月 16 日,当时看中的是 Zig 对底层控制和性能的重视。Bun 的初版由作者在奥克兰公寓中用一年写出,而且是在 LLM 普及之前完成。作者明确说:如果没有 Zig,他不可能一年内做出这么多东西。
但是 Bun 的范围也异常大,它不仅是运行时,还覆盖包管理、测试、打包、模块解析和大量 Node.js API。
Bun 的功能面
- JavaScript、TypeScript、CSS 转译、压缩和打包。
- npm 兼容包管理器。
- 类似 Jest 的测试运行器。
- Node.js 与 TypeScript 兼容的模块解析。
- HTTP/1.1 与 WebSocket 客户端。
fs、net、tls等 Node.js API 实现。
现实规模
Bun CLI 现在每月下载量超过 2,200 万。Claude Code、OpenCode 等工具把 Bun 作为运行时,Vercel、Railway、DigitalOcean 等平台也提供一方支持。
Bun v1.3.14 修过的一类问题
作者列出了一批近期修复的 bug,用来说明问题不是单点偶发,而是生命周期、手动释放、异步回调和 GC 交互带来的系统性风险。
| 类别 | 示例 | 风险 |
|---|---|---|
| use-after-free | node:zlib、node:http2、UDPSocket.send() 中由异步写入、重入回调、hashmap rehash 或 JS coercion 触发。 | 崩溃、内存破坏。 |
| 越界读写 | Buffer#copy、Buffer#fill、UDPSocket.sendMany() 在用户回调改变底层状态后访问错误。 | 崩溃或安全风险。 |
| 内存泄漏 | crypto.scrypt、SSLWrapper.init、tlsSocket.setSession()、fs.watch()、DuplexUpgradeContext。 | 长期运行进程内存增长。 |
| double-free | CSS parser 在 vendor prefix 和多层 background 组合下触发。 | 进程崩溃。 |
| 并发竞态 | MessageEvent 的 GC marker 线程可能观察到撕裂的 variant。 | 难复现崩溃。 |
团队已经做过的防线
作者并不是因为没有测试才做重写。Bun 已经给 Zig 编译器打补丁加入 Address Sanitizer,所有提交都跑 ASAN 测试;Windows 发布 Zig 的 ReleaseSafe 构建;用 Fuzzilli 24/7 fuzz 运行时 API;并维护大量端到端内存泄漏测试。作者的判断是:这些已经超过许多项目,但仍不能从根上阻止这类问题反复出现。
为什么是 Rust
文章强调:作者不把问题归咎于 Zig。Zig 让 Bun 得以出现,作者也说会一直感激 Zig。但 Bun 的特殊性在于,它把 JavaScriptCore 这类垃圾回收世界和大量手动管理内存的原生代码混在一起。每次分配都要回答:何时释放、是否只释放一次、JS 异常是否正确处理、GC 指针是否能被 conservative stack scanner 看见、这块内存到底由 GC 管还是由手动代码管。
| 语言 | 清理机制 | 对 Bun 的含义 |
|---|---|---|
| Zig | defer、errdefer | 显式、无隐藏控制流,但每个调用点都要正确写出清理逻辑。 |
| C++ | 析构函数、move 语义 | 能减少包装代码,但仍主要依赖 style guide、review、ASAN 等防线。 |
| Rust | Drop、所有权、借用检查 | 许多 use-after-free、double-free 和忘记释放的问题会变成编译错误或自动清理。 |
为什么不继续靠 style guide
一个可选方向是在 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 更像这样:
fn foo(a: *TCPSocket) !void {
const b = try do_something_with_a(a);
// ...
}
自制智能指针的 ergonomics 不如 Rust,也没有 Rust 的编译期保证。因此作者决定测试一个激进想法:花一周看看 Anthropic 新模型能否把 Bun 改写成 Rust。
迁移策略
作者认为,历史上重写通常是糟糕选择。Bun 不含注释已有 535,496 行 Zig,换语言重写本来可能需要小团队一年,而且会冻结 bugfix、安全修复和功能开发。唯一相对低风险的方式,是尽可能机械地从 Zig port 到 Rust,尽量不改变行为,并继续使用既有测试套件。
增量迁移还是一次完成?
作者选择一次完成。他从过去把 esbuild 转译器从 Go 移植到 Zig 的经验出发,认为增量迁移会制造大量临时代码,短中期都会痛苦。
怎样保持同一个 Bun?
迁移后的 Rust 代码要像 Zig 代码被转译过去一样,架构、性能和功能集保持一致。先忠实移植,等 Bun v1.4 发布后再逐步减少 unsafe、变得更 idiomatic Rust。
Claude 工作流
作者把很多日常工程工作抽象为“写代码、审查、修正”的循环。Bun 的 Rust 迁移使用了约 50 个 Claude Code 动态工作流,连续运行 11 天。作者大部分时间都在监控这些 workflow,阅读输出,发现问题后再提示 Claude 修改工作流本身。
// Pseudocode, not real code:
let task;
while ((task = todoList.pop())) {
const result = task();
const feedback = await Promise.all([review(result), review(result)]);
await apply(feedback, result);
}
主要工作流
- 生成 porting guide,把 Zig 模式和类型映射到 Rust 模式和类型。
- 把每个
.zig文件机械移植成.rs文件,并遵守PORTING.md和LIFETIMES.tsv。 - 修复每个 crate 的编译错误。
- 让
bun test、bun build等子命令重新工作。 - 让 Bun 的完整测试套件通过。
- 执行大规模重构和清理。
对抗式审查
作者认为,怎样审查一个新增超过 100 万行的 PR,是这次迁移的关键问题。方案是:语言无关测试套件、百万级断言、对抗式 code review,以及出现问题时修正生成流程,而不是手工修单点代码。
对抗式审查让另一个 Claude 在独立上下文里专门寻找代码为什么错。写代码的 Claude 不审查,审查的 Claude 不实现。常见配置是 1 个 implementer 配 2 个或更多 reviewer。
作者的原则:实现者想合并,审查者只负责找 bug。Claude 也会有类似倾向,因此用分离上下文来减少“自己证明自己正确”的偏差。
一个真实审查例子:异步 close
某段 Rust 代码能编译,也看起来合理,但 reviewer 发现 uv_close 是异步的:libuv 会持有 raw handle 指针到下一次 event loop tick,再调用 close callback 释放。如果 Box 在 match 分支结束时 drop,libuv 就握着已释放内存,回调里还会二次释放。
for stdio in [spawned_stdout, spawned_stderr] {
match stdio {
StdioResult::Buffer(mut pipe) => {
// pipe: Box<uv::Pipe> - hand it to libuv to close
pipe.close(Subprocess::on_pipe_close)
}
StdioResult::Fd(fd) => fd.close(),
StdioResult::Unavailable => {}
}
}
修复方式是把 Box 泄漏给 libuv 生命周期管理:
Box::leak(pipe).close(Subprocess::on_pipe_close)
准备阶段
写代码前,作者先花约 3 小时和 Claude 讨论如何把 Bun 的 Zig 模式尽量贴近地映射到 Rust。Claude 把讨论结果写成 PORTING.md。随后又让 workflow 分析代码库中每个 struct field 的生命周期,先找复杂字段,提出 lifetime,再让两个对抗 reviewer 检查,最后写进 LIFETIMES.tsv。
试跑与早期失败
作者没有一开始就让 Claude 改 1,448 个 .zig 文件,而是先选 3 个文件:1 个 implementer 写 Rust,2 个 reviewer 检查行为是否匹配 Zig,是否符合 porting guide 和 lifetime guide,再由 1 个 fixer 应用建议。
首次扩大到全量时,Claude 之间互相踩工作区:一个运行 git stash,另一个 git stash pop,还有一个 git reset HEAD --hard。作者随即修改工作流规则:禁止运行 git stash、git reset 等会影响共享状态的 git 命令,也禁止慢命令和 cargo。之后又改成 4 个 worktree 分片,每个 worktree 运行 16 个 Claude。
真正写代码
在峰值时,Claude 每分钟写约 1,300 行代码。每行代码都由两个独立对抗 reviewer 审查,再经过修复后提交。作者强调:这时“完全还不能工作”。这只是把代码先写出来。
验证、编译与合并
把编译错误变成工作队列
Bun 原来的 Zig 代码基本是一个编译单元。作者想把 Rust 代码拆成约 100 个 crate,以加快编译,但这要求避开循环依赖,同时尽量少改原实现。循环依赖修正后暴露出约 16,000 个编译错误。对一个人来说这很多,但对 64 个并行 Claude 不算离谱。
- 对每个 crate 运行
cargo check。 - 按文件归类错误并保存。
- 修复该 crate 内所有编译错误。
- 由 2 个对抗 reviewer 审查。
- 由 1 个 fixer 应用修正。
为了避免互相踩,cargo check 只在每轮开头运行,git 操作也只放到最后。
又一次失败:别用空实现糊弄编译器
Claude 曾把“让所有 crate 编译”理解成“先把有编译错误的函数改成空实现、假实现,或者直接返回一个占位结果”。这样确实可能让编译器暂时不报错,但真实功能并没有修好。Claude 还开始写很长的注释解释这些临时绕法为什么可以接受。作者给 reviewer 加了一条规则:如果需要一整段注释来证明绕法没问题,那代码就是错的,应该修代码。几个小时后,这类问题停止出现。
Smoke tests 与本地测试
当 cargo check 通过后,下一步是让二进制能编译并运行 bun --version。最初有链接错误,之后启动即 panic。再下一步是让 bun test <file> 工作,然后开始跑测试文件。workflow 会保存失败 stacktrace 和子命令,再由 implementer 修、reviewer 查、fixer 应用。
测试套件本身也有挑战:内存泄漏测试、超过一分钟的集成测试、压力测试会耗尽 TCP socket、读写数 GB 磁盘、创建约 1 万个进程。团队用 systemd-run 和 cgroups 限制内存、CPU,并隔离 pid namespace,但机器仍多次耗尽磁盘并崩溃。
CI 全平台转绿
第一次 CI 运行两天后,失败测试文件从 972 个降到 23 个。又过一天半,Linux 全绿,作者第一次觉得这次 Rust 改写真的要成功。最终 6 个平台全部通过:macOS x64、Linux arm64、Linux x64、macOS arm64、Windows x64、Windows arm64。
合并不是发布
当 Bun 的测试套件在所有平台 CI 上 100% 通过,并且作者人工确认测试确实在运行、没有被跳过后,他本地跑了许多命令,然后合并了 PR。文章强调:合并到 main 不等于正式版本发布。那时作者有信心继续推进并承诺迁移,但还没有信心立刻发布。
成本与统计
峰值时,团队同时跑 4 个 workflow,每个在独立 worktree 中,每个 workflow 有 16 个 Claude,总计约 64 个 Claude 并行。
6,778
11 天内产生的提交数。
0
测试被跳过或删除。
$165k
按 API 价格估算的迁移前成本。
测试规模
| 平台 | 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 |
Token 与代价
迁移前共消耗约 59 亿 uncached input tokens、6.9 亿 output tokens,以及 720 亿 cached input token reads。作者估算,如果人工完成,需要 3 个完全理解代码库的工程师做一年;那一年里团队无法继续改进 Node.js 兼容性、修 bug、修安全问题或做新功能。因此现实替代方案不是人工重写,而是继续永远修文章开头那些 bug。
迁移错误
Rust 改写的重点是稳定性,但如此巨大的变更不可能零回归。文章称这次重写引入了 19 个已知回归,并且都已经修复。多数回归来自“两种语言语法看起来一样,但语义不同”。
debug_assert! 中的副作用
Zig 的 assert 是函数,参数在所有构建中都会执行;Rust 的 debug_assert! 是宏,release build 会擦掉整个表达式,包括里面的副作用调用。
// 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 会把文件加入前端 dev server 的热更新图。release build 中它不再执行,导致某些 HTML routes + React 的 HMR 场景坏掉。
奇数长度 slice
Bun 的 Zig helper reinterpretSlice(u16, bytes) 会用 @divTrunc 忽略末尾一个奇数字节;Rust 的 bytemuck::cast_slice 遇到这种输入会 panic。修复方式是回到旧行为:忽略最后一个奇数字节。
边界检查
macOS 和 Linux 上,Bun 的 Zig 代码用 ReleaseFast 编译,会移除 bounds checks;Rust release build 保留边界检查。迁移中一个占位常量把模块解析器可 intern 的长文件名上限从约 840 万降到 270,272,并让一个从 Zig 带来的 off-by-one 更容易触发。Rust 会 panic;Zig 如果用 ReleaseSafe 也会 panic,但当时只有 Windows 这么构建。
comptime format strings
Zig 的 Output.pretty 在 comptime 处理 <r>、<d> 颜色标记,参数替换前标记已经消失。Rust 函数没有 comptime 参数,迁移后只能看到完成后的字符串,可能错误处理参数中的内容。最终 Rust 版需要做成宏:
bun_core::pretty!("<r>{}<r>", hyperlink)
Rust 版收益
截至文章撰写,Bun v1.4.0 修复了 128 个能在 v1.3.14 复现的 bug,范围从内存泄漏、崩溃到帮助文本颜色错误。
内存使用降低
Rust 的 Drop 会在值离开作用域时自动调用清理逻辑。相比之下,Zig 的 defer 需要在每个调用点显式写出,容易漏掉或重复清理。
impl Drop for Bytes {
fn drop(&mut self) {
if !self.pinned.is_empty() {
JSC__JSValue__unpinArrayBuffer(self.pinned);
}
}
}
const bytes: ArrayBuffer = try .fromPinned(global, value);
defer bytes.unpin();
Drop 修复了 Bun 中多个错误处理路径里的文件路径内存泄漏。
可检测的内存泄漏全部修掉
团队改进了 LeakSanitizer 集成,使其能追踪所有 native code 内存分配。一个例子是:每次进程内 Bun.build() 都会泄漏数 MB 的 parsed source text 和 AST symbol table。开发服务器这类反复 build 的工具最终会耗尽内存。
// 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",
});
}
| Build 次数 | 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 |
二进制更小
Rust 改写初始阶段让 Windows 二进制缩小 3.8 MB、macOS 缩小 5.5 MB、Linux 缩小 6.8 MB。原因之一是原 Zig 代码用了太多 comptime。随后团队又通过 identical code folding、移除 ICU 未用数据、用 zstd 字典按需解压 ICU 小部分等方式继续缩小。结合 Rust 改写、ICU 变化和链接优化,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 parser 以及 Bun 中其他递归下降 parser,例如 JSON、YAML、JavaScript、TypeScript 等,现在使用更少栈空间。Rust 的 LLVM IR codegen 会为不再使用的栈变量发出 llvm.lifetime.start 和 llvm.lifetime.end,让 LLVM 复用栈 slot。以前团队需要把特别大的函数拆成很多小函数来规避相关问题。
快 2% 到 5%
Rust 支持 C/C++ 与 Rust 跨语言 link-time optimization,能跨语言内联。Bun v1.4.0 在 Linux x64 的多个 benchmark 中比 v1.3.14 快约 2% 到 5%。
| HTTP server | Bun v1.3.14 | Bun v1.4.0 | 变化 |
|---|---|---|---|
Bun.serve | 169.6k req/s | 177.7k req/s | +4.8% |
node:http | 103.8k req/s | 108.5k req/s | +4.5% |
| Elysia | 158.9k req/s | 163.3k req/s | +2.8% |
| express | 64.5k req/s | 66.6k req/s | +3.2% |
| fastify | 91.5k req/s | 95.9k req/s | +4.8% |
| 工作负载 | 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 rewrite 上发布了 Prisma Compute public beta。Alexey Orlenko 表示,他们原本遇到内存泄漏,以及 VM pause/resume 后连接池无法恢复的问题;Rust rewrite 出现后,他们用同样失败模式测试,结果表现良好。
Claude Code v2.1.181(6 月 17 日发布)及之后版本使用 Rust port 的 Bun。Linux 启动速度快了 10%,除此之外几乎没人注意到差异。作者认为这很好:底层迁移应该尽量无感。
如何试用
Bun v1.3.14 是最后一个 Zig 版本;Bun v1.4.0 是第一个 Rust 版本,文章发布时已经在 canary 可用。
bun upgrade --canary
可维护性
作者说,对他和团队而言,新 Rust 代码库和旧 Zig 代码库很像。理解原 Zig 代码的人,也能理解机械翻译后的 Rust。审查原始 PR 时,作者主要检查对抗审查代理是否能正确捕捉 Zig 与 Rust 的差异、是否遵守 porting guide 和 lifetime guide,同时人工并排阅读大量 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) {
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 {
if existing == symbol::Kind::Import {
return SymbolMergeResult::ReplaceWithNew;
}
}
// ...
}
继续安全化
合并 Rust port 后,团队已经完成 11 轮 Claude Code Security 安全审查并处理发现的问题。还给 Bun 的每个 parser 加上 24/7 coverage-guided fuzzing,包括 JavaScript、TypeScript、JSX、CSS、JSON5、JSONC、TOML、YAML、Markdown、INI、Bun Shell scripts、semver ranges、.patch 文件和 CSS colors。fuzzer 发现 bug 后会自动发给 Claude 提交复现与修复 PR,再由人类 review。到文章撰写时,fuzzer 已执行 parser 约 1,000 亿次,带来约 15 个 PR。
文章还给出 Rust 代码中 unsafe 的现状:约 4% 的 Rust 代码位于 unsafe block 中,约 13,000 个 unsafe 关键字,对应约 27,000 行 unsafe 相关代码,总 Rust 代码约 780,000 行。其中 78% 的 unsafe block 是单行,通常是来自 C++ 的指针或一次 C 库调用。作者预计随着从忠实 Zig port 重构为 idiomatic Rust,这个数字会下降;但 Bun 仍会继续使用 JavaScriptCore 等 C/C++ 库,因此 unsafe 永远会比纯 Rust 项目更多。
结尾判断
Bun v1.4 让 Bun 更快、更小、更省内存,也给团队带来了几套能持续发现问题的安全网。这里的重点不是某一个工具名,而是问题能更早暴露:有些在编译时发现,有些在测试时发现,有些通过不断喂异常输入来发现。
- Rust borrow checker:Rust 编译器里的借用检查器,会检查一个值什么时候被谁拥有、什么时候还能被引用、什么时候已经不能用了。很多 use-after-free、重复释放、悬空引用,会直接变成编译错误。
- Miri:Rust 的测试检查工具,会用更严格的方式运行一部分测试,帮助发现错误指针使用、越界访问等平时不容易暴露的问题。
- LeakSanitizer:内存泄漏检测工具,用来检查 native 内存有没有分配后忘记释放。Bun 混合了 Rust、C、C++ 和 JavaScriptCore,这类检查仍然很重要。
- 24/7 coverage-guided fuzzing:全天候自动模糊测试。它会不断生成各种输入喂给 parser,例如 JS、TS、CSS、JSON、TOML、YAML 等解析器;“coverage-guided”表示它会优先探索能触发新代码路径的输入,而不是完全随机乱试。
还有很多重构要做,但开局很好。
作者最后的判断是:这次 Rust rewrite 原本可能需要一个完全熟悉代码库的工程师团队做一年。现在,一个工程师借助 Fable 并密切监控 Claude Code,从开始到所有平台 100% 测试通过,用了 11 天。
一年后的今天,一个工程师能做的事情已经多得多。