Bun Blog · 忠实中文重排版

Rewriting Bun in Rust

Jarred Sumner 于 2026 年 7 月 8 日发布的文章,讲述 Bun 从 Zig 机械迁移到 Rust 的原因、方法、验证流程、代价、回归和收益。

范围
约 53.5 万行 Zig 迁移到 Rust
周期
11 天,所有平台测试通过
规模
6,778 个提交,约 64 个 Claude 并行
目标
系统性减少内存泄漏、UAF、double-free

一眼看懂

核心结论

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 客户端。
  • fsnettls 等 Node.js API 实现。

现实规模

Bun CLI 现在每月下载量超过 2,200 万。Claude Code、OpenCode 等工具把 Bun 作为运行时,Vercel、Railway、DigitalOcean 等平台也提供一方支持。

Bun v1.3.14 修过的一类问题

作者列出了一批近期修复的 bug,用来说明问题不是单点偶发,而是生命周期、手动释放、异步回调和 GC 交互带来的系统性风险。

类别示例风险
use-after-freenode:zlibnode:http2UDPSocket.send() 中由异步写入、重入回调、hashmap rehash 或 JS coercion 触发。崩溃、内存破坏。
越界读写Buffer#copyBuffer#fillUDPSocket.sendMany() 在用户回调改变底层状态后访问错误。崩溃或安全风险。
内存泄漏crypto.scryptSSLWrapper.inittlsSocket.setSession()fs.watch()DuplexUpgradeContext长期运行进程内存增长。
double-freeCSS 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 的含义
Zigdefererrdefer显式、无隐藏控制流,但每个调用点都要正确写出清理逻辑。
C++析构函数、move 语义能减少包装代码,但仍主要依赖 style guide、review、ASAN 等防线。
RustDrop、所有权、借用检查许多 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,尽量不改变行为,并继续使用既有测试套件。

问题 1

增量迁移还是一次完成?

作者选择一次完成。他从过去把 esbuild 转译器从 Go 移植到 Zig 的经验出发,认为增量迁移会制造大量临时代码,短中期都会痛苦。

问题 2

怎样保持同一个 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.mdLIFETIMES.tsv
  • 修复每个 crate 的编译错误。
  • bun testbun 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 stashgit reset 等会影响共享状态的 git 命令,也禁止慢命令和 cargo。之后又改成 4 个 worktree 分片,每个 worktree 运行 16 个 Claude。

真正写代码

在峰值时,Claude 每分钟写约 1,300 行代码。每行代码都由两个独立对抗 reviewer 审查,再经过修复后提交。作者强调:这时“完全还不能工作”。这只是把代码先写出来。

验证、编译与合并

把编译错误变成工作队列

Bun 原来的 Zig 代码基本是一个编译单元。作者想把 Rust 代码拆成约 100 个 crate,以加快编译,但这要求避开循环依赖,同时尽量少改原实现。循环依赖修正后暴露出约 16,000 个编译错误。对一个人来说这很多,但对 64 个并行 Claude 不算离谱。

  1. 对每个 crate 运行 cargo check
  2. 按文件归类错误并保存。
  3. 修复该 crate 内所有编译错误。
  4. 由 2 个对抗 reviewer 审查。
  5. 由 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

测试被跳过或删除。

API 成本

$165k

按 API 价格估算的迁移前成本。

测试规模

平台expect() 调用测试数文件数
Debian 13 x641,386,82660,6244,174
macOS 14 arm641,259,95358,8504,175
Windows 2019 x641,007,54457,3374,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.14Bun v1.4.0
5001,914 MB526 MB
1,0003,506 MB586 MB
1,5005,097 MB608 MB
2,0006,745 MB609 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 canaryWindows76 MB
Bun v1.3.14Windows94 MB
Bun v1.4.0 canaryLinux70 MB
Bun v1.3.14Linux88 MB

栈空间使用降低

TOML parser 以及 Bun 中其他递归下降 parser,例如 JSON、YAML、JavaScript、TypeScript 等,现在使用更少栈空间。Rust 的 LLVM IR codegen 会为不再使用的栈变量发出 llvm.lifetime.startllvm.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 serverBun v1.3.14Bun v1.4.0变化
Bun.serve169.6k req/s177.7k req/s+4.8%
node:http103.8k req/s108.5k req/s+4.5%
Elysia158.9k req/s163.3k req/s+2.8%
express64.5k req/s66.6k req/s+3.2%
fastify91.5k req/s95.9k req/s+4.8%
工作负载Bun 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 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 天。

一年后的今天,一个工程师能做的事情已经多得多。