Bun 被 Anthropic 收购后用 Rust 重写,月下载超 2200 万
阅读原文· bun.comBun 创始人把 54 万行 Zig 用 Claude 11 天重写为 Rust,对抗式审查和动态工作流的细节是近期最值得看的 AI 辅助工程实战复盘,做基础设施的可以认真读。
Bun 于 2025 年 12 月被 Anthropic 收购,作者使用预发布版 Claude Fable 5 进行了大量 Rust 重写。Bun 最初用 Zig 在一年内构建,如今 CLI 月下载超 2200 万,被 Claude Code 等采用。广泛功能带来稳定性挑战,v1.3.14 修复了多项 use-after-free、内存泄漏等 bug。团队通过 ASAN、Fuzzilli 模糊测试等系统性预防,并借助 Rust 的内存安全特性减少此类缺陷。
用 Rust 重写 Bun
Jarred Sumner
·
2026 年 7 月 8 日
披露声明:Bun 于 2025 年 12 月被 Anthropic 收购。我和 Bun 团队的其他成员目前都在 Anthropic 工作。我在 Rust 重写过程中大量使用了 Claude Fable 5 的预发布版本。
Bun 最初是将 esbuild 的 JavaScript 和 TypeScript 转译器从 Go 语言逐行移植到 Zig 语言。我在 2021 年 4 月 16 日写下了第一行 Zig 代码。在 Hacker News 上看到那篇单页的 Zig 语言参考文档后,我被其底层控制力和对性能的极致追求深深吸引,于是押注了 Zig。
从一开始,Bun 的范畴就极其庞大:
- JavaScript、TypeScript 和 CSS 的转译器、压缩器和打包器
- 兼容 npm 的包管理器
- 类似 Jest 的测试运行器
- 兼容 Node.js 和 TypeScript 的模块解析
- HTTP/1.1 和 WebSocket 客户端
- Node.js API 实现,如 fs、net、tls 以及数十个其他模块
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 中,当对 zlib、Brotli 或 Zstd 流调用 .reset() 方法,而线程池中仍有异步的 .write() 操作正在进行时,发生堆释放后使用(heap-use-after-free)崩溃。
- 在 node:zlib 中,当 onerror 回调对原生句柄执行了重入的 write() 后紧接着执行 close() 时,发生释放后使用(use-after-free)崩溃。
- 在 node:http2 中,当重入的 JS 回调(例如在超时监听器、选项 getter 或写入回调中调用 session.request())触发了哈希表重新哈希,导致内部流指针失效时,发生释放后使用(use-after-free)崩溃。
- 在 UDPSocket.send() 和 sendMany() 中,当用户代码在 valueOf() 或 toString() 回调中,在捕获载荷与实际发送之间分离了 ArrayBuffer 时,发生释放后使用(use-after-free)问题。
- 当参数强制转换过程中,valueOf 回调函数分离或调整底层 ArrayBuffer 大小时,Buffer#copy 和 Buffer#fill 中发生崩溃和越界读取。
- 当通过用户 JS 回调在迭代中途更改套接字连接状态时,UDPSocket.sendMany() 中出现堆越界写入。
- crypto.scrypt 中的内存泄漏:当输出缓冲区分配失败时,回调函数以及受保护的密码/盐缓冲区从未被释放。
- SSLWrapper.init 在错误路径上泄漏了经过 strdup 处理的密码短语。
- tlsSocket.setSession() 中的内存泄漏:由于在 d2i_SSL_SESSION 之后缺少 SSL_SESSION_free,每次调用都会泄漏一个 SSL_SESSION(每次调用约 6.5 KB)。
- 内存泄漏:由于引用计数下溢,导致每个监视器被永久固定为 GC 根,fs.watch() 监视器在调用 .close() 后从未被垃圾回收。
- 当 background-clip 包含厂商前缀和多层背景时,CSS 解析器中发生双重释放崩溃。
- DuplexUpgradeContext 从未被释放——每次 tls.connect({ socket: duplex }) 调用都会完全泄漏。
- MessageEvent 中的竞态条件崩溃:当通过 BroadcastChannel 或 MessagePort 并发访问时,GC 标记线程可能在 m_data 中观察到撕裂的变体。
我们可以一直零散地修复这类错误,但我们有责任不辜负依赖我们的用户,做得比那更好,并系统地防止这类错误再次发生。
我们已经在做的事情
- 我们修补了 Zig 编译器以添加地址消毒器支持。我们在每次提交时都使用 ASAN 运行测试套件。
- 我们在 Windows 上发布启用了 Zig 安全检查的 ReleaseSafe 构建版本。
- 我们使用 Fuzzilli(V8 和 JavaScriptCore 使用的 JavaScript 引擎模糊测试器)全天候对 Bun 的运行时 API 进行模糊测试。
- 我们有大量的端到端内存泄漏测试。
这比许多项目做得都要多。
只要足够聪明,不犯错误就行了?
我们的 bug 修复列表让人感觉很糟糕,我也厌倦了带着对 Bun 崩溃的担忧入睡。我不认为这是 Zig 的问题——Zig 的其他用户并没有我们遇到的这些 bug,而且将垃圾回收与手动管理的内存混合使用,对于软件需求来说是一件相当罕见的事情,没有哪种语言会真正为此设计。如果没有 Zig,我们不可能走到今天这一步,对此我将永远心怀感激。直到最近,对于像 Bun 这样的项目来说,编程语言的选择还是一项单向决策。
JavaScript 是一种垃圾回收语言,而像 JavaScriptCore(以及 V8)这样的现代 JavaScript 引擎在异常处理和垃圾回收方面有严格的规定。Zig 和 C 一样,不会为你管理内存,对于许多项目来说,这种权衡正是使用 Zig 的绝佳理由。Zig 没有构造函数/析构函数,大多数清理工作都期望在每个调用点通过 `defer` 显式地写出来。
对于 Bun 来说,正确处理垃圾回收值和手动管理值的生命周期一直是稳定性问题的主要来源——最常见的是小的内存泄漏,偶尔也会导致崩溃。每一次内存分配都必须经过细致的审查。这些字节在哪里被释放?我们如何确保它只被释放一次?我们是否正确检查了 JavaScript 异常?这个垃圾回收指针对于保守的栈扫描器是否可见?这块内存是垃圾回收内存还是手动管理的内存?
对于稳定性问题,越早发现越好。模糊测试发生在代码合并之后。持续集成发生在代码推送时。运行时安全检查与地址消毒器发生在代码运行时(希望在开发阶段,在持续集成之前)。
减少这类问题的一种常见方法是,确保需要清理的代码的清理逻辑总是恰好执行一次。Zig 被设计成一种没有隐藏控制流的简单语言,因此它倾向于使用显式的 `defer` 关键字在作用域结束时运行代码,而不是像 C++ 的隐式 `~Destructor` 或 Rust 的隐式 `Drop`。
| 语言 | 清理机制 |
|---|---|
| Zig | defer, errdefer |
| C++ | ~Destructor, &&Move |
| Rust | Drop |
对于 Zig 代码,我们究竟应该在何时运行清理代码?如果我们将同一个 `*T` 传递给许多不同的函数,我们如何知道它何时不再可访问并可以被清理?当某些函数在调用后需要继续引用该内存时,这又如何运作?我们当前的方法是混合使用以下几种方式:
- 竞技场生命周期,其中可访问的作用域是明确的(解析器状态不会逃逸出调用函数,因此 AST 节点在那里是一个不错的选择)
- 引用计数
- 非常仔细地关注
许多项目选择通过风格指南来回答这类问题。TigerBeetle 的 TigerStyle 是 Zig 中的一个例子,Google 长达 31,000 字的 C++ 风格指南则是另一个例子。风格指南面临的挑战在于执行。你如何确保风格指南得到遵守?从历史上看,代码审查是答案,并通过 linter 和静态分析器进行尽力而为的执行。
拥有一个严格的风格指南,并在类型系统中明确写出清晰的所有权期望,对 Bun 来说是一个切实可行的选择。由于 Zig 没有运算符重载,我们最终可能会看到很多类似这样的代码:
fnfoo(a_ptr: SharedPtr(TCPSocket)) !void {
const a: *TCPSocket = a_ptr.get();
defer a_ptr.deref();
const b = trydo_something_with_a(a);
defer b.deref();
// ...
}
这比我们期望的 Zig 代码的易用性要差:
fnfoo(a: *TCPSocket) !void {
const b = trydo_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?
该列表中的很大一部分错误是释放后使用、双重释放以及在错误路径中“忘记释放”。在安全的 Rust 中,这些是编译器错误,并且通过 Drop 实现类似 RAII 的自动清理。编译器错误比风格指南提供了更好的反馈循环。
从历史上看,重写是一个糟糕的主意。排除注释,Bun 有 535,496 行 Zig 代码。用另一种语言重写,需要一个小型工程师团队花费整整一年时间。这意味着在此期间要冻结 bug 修复、安全修复或功能开发。要交付可用的产品,风险最小的方式是机械地将 Zig 移植到 Rust,行为变更尽可能少,并使用我们已经在 Bun 上使用的同一套测试套件。
幸运的是,Bun 自己的测试套件是用 TypeScript 编写的,这意味着它不依赖于运行时的编程语言。
一年内对用户没有任何影响,这不是一个我们可以考虑的可行方案。因此,通过代码风格来强制执行以修复稳定性问题是我们最好的选择,也是我们在向 Bun 的代码库添加受 Rust 启发的智能指针时的计划。
但老实说,我并不想这么做。自制的智能指针在人体工程学上比 Rust 更差,而且没有任何保证。
那么,如果我花一周时间测试 Anthropic 的新模型能否用 Rust 重写 Bun 呢?
起初,我并没有指望它能成功。几天后,测试套件中通过率很高的部分开始通过,我看到新的 Rust 代码与原始的 Zig 代码库高度匹配。我的看法从“这值得一试”变成了“我要合并这个”。
Claude,用 Rust 重写 Bun。
有很多方法会把这件事搞砸。例如,提示 Claude“用 Rust 重写 Bun。不要犯任何错误。”然后祈祷它能成功,这并不是我的做法。
想想一个人会怎么做。第一个大问题是:
增量重写?还是全部一次性重写?
根据我当初为 Bun 的初始版本(没有使用大语言模型)将 esbuild 的转译器从 Go 移植到 Zig 的经验,一次性全部重写更好。增量重写会添加你希望最终被删除的临时代码,并且在短期到中期内会很痛苦。
第二个大问题:怎么做?
我们如何让用 Rust 编写的 Bun 保持与之前相同的 Bun,拥有相同的架构、性能和功能集,同时还能获得 Rust 的语言特性(如借用检查器)?我们如何确保团队在重写后仍然能够维护它?
做一次重写,看起来就像我们把 Zig 代码转译成了 Rust。在 Bun v1.4 发布后,我们可以逐步重构它,减少不安全用法,使其看起来更像地道的 Rust。
只有这两个大问题。其他都是战术层面的。
编写与审查代码的循环
软件工程师日常工作中的大量工程任务,可以过度简化为循环。
// Pseudocode, not real code:
let task;
while ((task = todoList.pop())) {
const result =task();
const feedback =awaitPromise.all([review(result), review(result)]);
awaitapply(feedback, result);
}
一个任务带有一些上下文(一个 Jira 工单、一个 GitHub Issue 等)。结果就是你为修复它而编写的代码。代码审查者审查这些变更,检查是否存在回归问题和正确性。然后你处理反馈意见。
我使用 Claude Code 中大约 50 个动态工作流,在 11 天内持续运行,用 Rust 重写了 Bun。
每个动态工作流都是这样一个循环——一个用于以下事项的工作流:
- 生成一份移植指南,将 Zig 的模式和类型映射到 Rust 的模式和类型
- 机械地将每个 .zig 文件移植为 .rs 文件,匹配 PORTING.md 和 LIFETIMES.tsv
- 修复每个 crate 的编译器错误
- 让 `bun test` 或 `bun build` 等子命令能够工作
- 让 Bun 整个测试套件中的每个测试都通过
- 几次大型重构和清理工作
在那 11 天的大部分时间里(以及之后),我监控着工作流——手动读取输出以检查问题和错误,并提示 Claude 编辑循环来修复问题。
你如何审查一个新增超过 100 万行代码的 PR?你如何开始建立必要的信心,以负责任地合并大量由大语言模型编写的代码?
一个包含百万条断言的、与语言无关的测试套件,对抗性代码审查,以及当确实出现问题时,修复生成代码的流程,而不是手动修复代码本身。
对抗性审查
对抗性审查要求 Claude(在一个单独的上下文窗口中)详尽地找出这些变更可能产生错误或无法工作的原因。
拆分上下文窗口
通常对于人类来说,审查代码的人不是编写代码的人。编写代码的人想要合并代码,这可能会使他们的行为偏向于在代码准备好之前就发布。
Claude 也是如此。编写代码的 Claude 希望代码被接受。审查代码的 Claude 则希望找出代码中的问题。
每个实现者配备 2 名或更多对抗性审查者。审查者的唯一工作:找出代码中的错误及代码无法运行的原因。实现者不参与审查。审查者不参与实现。
✻ claude code · 动态工作流
对抗性审查
对抗性审查在合并前发现的众多错误中的 3 个
错误 1/3 · 异步关闭
✻
claude
实现者
其上下文:原始的 .zig 文件、移植计划、自身的推理过程
✻
claude
对抗性审查者
其上下文:仅限差异(diff)。被告知假定代码是错误的。
✻
src/runtime/api/bun/js_bun_spawn_bindings.rs
· 编译通过
for
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 会保留原始句柄指针直到下一个循环周期,然后调用 on_pipe_close,该回调会释放分配的内存。但 `pipe` 是一个 Box,会在该 match 分支末尾被丢弃——导致 libuv 持有的是一块已释放的内存,而关闭回调随后会再次释放它。先是释放后使用(use-after-free),然后是双重释放(double-free)。
✻
Box
::
leak
(pipe)
.
close
(
Subprocess
::
on_pipe_close)
f0a454376c7 · win-review: js_bun_spawn_bindings.rs 在异步 uv_close 前泄漏 Box<uv::Pipe>,以避免 on_pipe_close 中的 UAF/双重释放
✻
src/runtime/node/node_fs.rs
· 编译通过
// 将 f64 秒拆分为 timespec 风格的 {sec, nsec}
let
sec
=
t
.
trunc
();
TimeLike
{
sec
:
sec
as
i64
,
nsec
:
((t
-
sec)
*
1e9)
as
i64
,
}
✻
对于负的、非整数的时间——即 1970 年之前的文件 mtime——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
,
7cc88f00141 · 跨平台审查修复:… node_fs win to_sys_time_like 使用 floor() 以确保当 t 为负数时 nsec∈[0,1e9) …
✻
src/css/values/color.rs
· 编译通过
// 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)` 这样只有第二个百分比被省略的情况,会在参数表达式中触发 panic,而 `unwrap_or` 根本来不及忽略它。`unwrap_or_else` 则接受一个闭包,保持惰性求值。
✻
let
p1
=
first
.
percentage
.
unwrap_or_else
(
||
1.0
-
second
.
percentage
.
unwrap
());
90111846a14 · phase-b2: color.rs gated_full_impl FULLY DISSOLVED(验证:parse_color_mix unwrap_or 急切求值 panic,Default CurrentColor vs transparent)
对抗性审查者实际捕获的三个 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 放到单独的工作树中,磁盘空间又不够,因为 Bun 的 git 仓库太大了,而且最终这些改动需要放在一起编译和查看。
于是,我要求 Claude 修改工作流,指示 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 地进行。
✻ claude code · 动态工作流
≈16,000
个剩余错误
周三,5 月 6 日,上午 12:40 PDT
errors.txt
0 次修复提交
错误
:在字段访问前解引用 *mut EventLoop
错误
:js_parser/ast/E.rs:为 Number/BigInt/RegExp 移植 json_stringify
错误
: NodeHTTPResponse.rs: 为 JSNodeHTTPResponse 的缓存访问器添加接线
error[E0034]
: 作用域中存在多个适用的项
error
: test_command.rs: 为 bun_sourcemap_jsc::code 添加接线覆盖层
error
: bundler/ungate_support.rs: 取消 bun_css 垫片的门控,使其指向真正的 ::bun_cs
error
: dns.rs: 实现 pending_cache_for/get_key/get_or_put_into_reso
error
: css/css_parser.rs: 移植 DefineShorthand 契约、parse_bundler
error
: runtime/crypto/mod.rs: create_crypto_error 委托给 boringss
error
: bun_core/fmt.rs: 实现 format_ip 重新借用(基于偏移的切片)
error
: event_loop/EventLoopTimer.rs: 从 bun.zig 移植 Timespec::ns
分配完毕 · 64 个 Claude
工作树 1
→
→
→
→
→
→
→
→
工作树 2
→
工作树 3
工作树 4
1 个修复
2 个审查
1 个应用
→ 提交按 crate 落地
bun_runtime
0
bun_bundler
0
bun_sql
0
bun_js_parser
0
bun_css
0
bun_http
0
bun_interchange
0
bun_sys
0
bun_core
0
bun_string
0
bun_logger
0
bun_uws_sys
0
bun_alloc
0
bun_collections
0
bun_ptr
0
bun_sourcemap
0
bun_safety
0
bun_glob
0
bun_dotenv
0
bun_router
0
bun_uws
0
bun_io
0
bun_ini
0
bun_lolhtml_sys
0
bun_test_runner
0
bun_cares_sys
0
bun_url
0
bun_picohttp
0
bun_clap
0
bun_boringssl
0
bun_watcher
0
bun_analytics
0
bun_libarchive
0
bun_paths
0
bun_aio
0
bun_options_types
0
bun_zlib
0
bun_crash_handler
0
bun_js_printer
0
bun_resolver
0
bun_http_jsc
0
bun_install
0
D 阶段的工作方式,从其 1,610 个真实提交(5 月 6 日,PDT)回放:cargo check 将约 16,000 个错误写入一个文件,按 crate 分组;工作流将它们分配给 64 个 Claude——在 4 个工作树上进行 16 轮循环,每个 Claude 负责修复,两个负责审查,一个负责应用。每个芯片都是一批真实提交:它落在其实际的 crate 上,只有在那时计数器才会移动。错误行是真实的提交主题。
最棘手的错误类型是循环依赖。
我们的 Zig 代码库原本是一个编译单元(实际上就是一个 crate)。我想把新的 Rust 代码库拆分成大约 100 个 crate,这样 Rust 编译速度会更快,但这需要在尽量减少与原始 Zig 实现差异的同时,避免循环依赖。我在开始 Rust 重写之前立即提交的 PR 并不够完善。我没有从头再来,而是运行了另一个工作流,对存在循环依赖的代码进行分类并全部记录下来——然后又运行了一个工作流来执行重构。
修复循环依赖暴露了大约 16,000 个编译器错误。对一个人来说这个数字很庞大,但对同时运行的 64 个 Claude 来说,并不算疯狂。
为了最大化并行度,工作流对每个 crate 进行了循环处理。
- 对于每个 crate,运行 cargo check,按文件分组输出结果,并将错误保存到一个文件中。
- 修复该 crate 内的所有编译器错误。
- 2 个对抗性审查者负责审查该 crate 的变更。
- 1 个修复者负责应用修复。
为了防止 Claude 之间相互干扰,cargo check 只在最开始运行,并且与其他运行一样,直到最后才使用 git。
又一次错误的开始。
Claude 将“让所有 crate 都能编译”理解为“将有编译错误的函数存根化”。Claude 还开始添加长得可疑的解释性注释来记录变通方案,所以我为对抗性审查者添加了这条拒绝规则:
如果你需要写一段很长的注释来证明变通方案是合理的,那说明代码本身有问题——请修复代码。
修改了一次提示词,几个小时后,这些问题就不再出现了。
冒烟测试。
模型很喜欢说“冒烟测试”。
一旦 cargo check 通过,下一步就是让它编译并运行 bun --version。它出现了链接器错误。然后,它在启动时立即崩溃了。
下一个目标是让它能够运行 bun test <file>。一旦这个成功了,我们就可以开始运行测试了!是时候运行另一个工作流了,这次是对 bun CLI 子命令进行循环处理:
- 将每个失败的堆栈跟踪连同其子命令一起保存到一个文件中。
- 对于按子命令分组的每个失败堆栈跟踪,由 1 个 Claude 负责修复。
- 2 个对抗性审查者。
- 1 个修复者负责应用建议。
让测试套件在本地通过。
这个工作流对测试文件进行了循环处理。
在代码库中按文件夹将大约 100 个随机测试文件分片到 4 个工作树之一。对于每个失败的测试,将堆栈跟踪和错误保存到一个文件中,1 名实现者提出修复方案,2 名对抗性审查者,然后 1 名修复者应用修复。
更多的错误开端
我们的测试套件包含大量内存泄漏测试和少数可能需要超过一分钟的集成测试——例如:一个运行 `next dev` 并检查热模块重载能否 100 次捕捉到变更的测试。其中几个测试在调试构建中会超时。
我们还有压力测试,会耗尽机器上的最大 TCP 套接字数、读写数 GB 数据到磁盘,以及衍生约 1 万个进程。
这需要比“请”字更强的隔离,因此我们使用了 systemd-run(cgroups)来限制内存和 CPU 使用,并隔离 PID 命名空间。即便如此,机器还是多次磁盘空间耗尽并崩溃。
让测试套件在 CI 中通过
首次 CI 运行两天后,失败列表从 972 个测试文件减少到 23 个。又过了一天半,Linux 完全变绿——这是第一次让人感觉这个 Rust 重写项目真的能行得通。
✻ claude code · 动态工作流
buildkite · 按平台统计的“奔向绿色”进度
Windows 最后完成 · 2024 年 5 月 11 日,太平洋夏令时上午 6:23
6 / 6
平台全部变绿
构建 #54202 · 太平洋夏令时 2024 年 5 月 14 日星期四上午 12:23
macOS x64
· 2 个分片
✓
Linux arm64
· 60 个分片
✓
Linux x64
· 60 个分片
✓
macOS arm64
· 4 个分片
✓
Windows x64
· 8 个分片
✓
Windows arm64
· 8 个分片
✓
✓ 全部 6 个平台变绿 · 构建 #54202 → 已合并
每个 CI 构建的测试分片,按平台划分,涵盖 135 个运行了测试的构建(从 BuildKite 中提取了 420 个)。亮绿色:所有分片通过。暗绿色:没有失败,但运行被提前终止(被取代)。红色:至少有一个分片失败。每条跑道在其完整套件首次通过时打上时间戳——Linux 的 60 个分片几乎比 Windows 早一整天变绿。各平台一直闪烁红色,直到最后一批失败测试被解决;最终的全绿构建是 #54202。
合并之前的其余时间进展顺利。工作流程循环修复每个平台的 CI 测试失败,直到不再有测试失败。还有几个与 Windows 相关的清理工作流,用于去重代码、减少不安全用法,以及进行常规代码清理。
合并 Rust 重写
一旦 Bun 的测试套件在所有平台上 100% 通过 CI(并且我手动验证了测试确实在运行,没有被跳过),我在本地运行了一系列命令进行测试——然后我按下了合并按钮。
合并到主分支并不是一个版本发布。此时,我有足够的信心继续推进并确认这次重写,但还没有足够的信心发布它。
统计数据
在高峰期,我们同时运行了 4 个这样的工作流,每个工作流位于独立的工作树中,每个工作流有 16 个 Claude。大约同时有 64 个 Claude 在运行。
git log · claude/phase-a-port
峰值:一分钟内 58 次提交
0
次提交
+0
行代码(含重写)
太平洋夏令时间 5 月 4 日星期一上午 7:05
全部 6,502 次提交(不含合并提交)已重放。
粉色
条主要表示新增代码;
青色
条主要表示删除代码。行计数器统计了沿途的每一次重写——最终合并的差异为 +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 天(否则我就得自己编写控制程序才能实现这一点)。
工作仍在继续
自合并 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 块内(约 27,000 行 / 780,000 行代码中约有 13,000 个 unsafe 关键字),其中 78% 的 unsafe 块只有一行——要么是一个来自 C++ 的指针,要么是对某个 C 库的一次调用。我预计随着我们从忠实的 Zig 移植版本(其中没有可 grep 到的 unsafe 关键字)重构为地道的 Rust,这个数字会逐渐下降,但我们仍将继续使用 JavaScriptCore 等 C 和 C++ 库,因此它始终会比纯 Rust 项目包含更多的 unsafe 代码。
移植过程中的错误
Rust 重写的重点是稳定性,但要交付如此大规模的变更而不引入任何回归问题是不可能的。
这次重写引入了 19 个已知的回归问题,每个问题都已被修复。
大多数回归问题源于那些在两种语言中语法相同但语义不同的代码。
debug_assert! 中的副作用
这两段代码看起来相似,但行为不同。Zig 的 assert 是一个函数,因此其参数在每次构建中都会执行。Rust 的 debug_assert! 是一个宏,因此在发布构建中,整个表达式都会被消除,包括 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` 会将一个文件添加到前端开发服务器的热重载图中。在发布构建中,它停止了运行,导致对于使用 React 且热重载文件失效的 HTML 路由项目,在某些情况下 HMR 出现故障:无法解构 'k' 的属性 'isLikelyComponentType'。调试构建则正常工作。#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 的发布构建则保留了它们。
Bun 的模块解析器会将长文件名登记到一个全局列表中,该列表会溢出到溢出块中。原始的 Zig 代码将每个块的大小设置为 count / 4,即 2048。移植后的代码留下了一个占位符:
/// ... so use a nonzero stand-in until Phase B threads the
/// per-instantiation value through.
pubconstBSS_OVERFLOW_BLOCK_SIZE:usize=64;
这将已登记文件名的上限从 840 万降低到了 270,272,而实际项目会达到这个上限,并且使得我们从 Zig 移植过来的 `ptrs[4095]` 差一错误变得可触发。Rust 会 panic,而不是写入超出末尾的位置。如果我们使用 ReleaseSafe(我们只在 Windows 上这样做),Zig 在这种情况下也会 panic。#31503
编译期格式字符串
`Output.pretty` 将 `<r>` 和 `<d>` 颜色标记重写为 ANSI 转义序列。在 Zig 中,`fmt` 是编译期参数,因此在参数被替换之前,这些标记就已经被处理掉了。Rust 函数没有编译期参数,所以 `Output::pretty` 只能看到最终的字符串,导致它也会重写参数中的标记。
// Zig:
pubinlinefnpretty(comptime fmt: string, args: anytype) void;
Output.pretty("<r>{f}<r>", .{hyperlink});
// Rust:
pubfnpretty(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
Bun 用 Rust 实现更好
到目前为止,Bun v1.4.0 修复了 v1.3.14 中可重现的 128 个 bug。这些 bug 涵盖了从内存泄漏到崩溃再到帮助文本颜色错误等各种问题。
减少的内存使用
Rust 拥有一个强大的语言级内存清理工具:Drop。当实现了 Drop 特性后,每次值离开作用域时,drop 函数都会被自动调用。
implDropforBytes {
fndrop(&mutself) {
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 符号表在它们所属的构建结束后仍然存活。
// 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",
});
}
在 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
— Bun (@bunjavascript) May 18, 2026
在初次缩减之后,团队探索了更多减少二进制体积的机会,使用了诸如相同代码折叠(Identical Code Folding)等链接器优化技术,移除了 ICU 中未使用的数据,并按需使用 zstd 字典对 libicu 的小部分内容进行惰性解压缩。
结合 Rust 重写、ICU 改动以及相同代码折叠,Bun 的二进制体积在 Linux 和 Windows 上缩减了约 20%。
| 版本 | 平台 | 体积 |
|---|---|---|
| Bun v1.4.0(预览版) | Windows | 76 MB |
| Bun v1.3.14 | Windows | 94 MB |
| Bun v1.4.0(预览版) | Linux | 70 MB |
| Bun v1.3.14 | Linux | 88 MB |
减少了栈空间使用
Bun 中的 TOML 解析器,以及所有其他递归下降解析器(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 functiondidnotthrow
Receivedvalue: {
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 能够复用栈空间槽位。这使得具有嵌套作用域的大型函数能够显著减少栈空间的使用。
此前,我们通过将特别大的函数重构为许多较小的函数,手动绕开了一个未解决的问题。
速度提升 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 吞吐量(请求/秒,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 秒 | 13.03 秒 | +4.5% |
| vite build(tsc + vite) | 1.69 秒 | 1.65 秒 | +2.2% |
| tsc -b --force | 0.94 秒 | 0.89 秒 | +4.7% |
生产环境
Prisma 在 Bun 的 Rust 重写版本上发布了 Prisma Compute 公开测试版。
"我们遇到了内存泄漏,以及虚拟机暂停并恢复后无法恢复的连接池问题。当 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 代码的片段:
pubfncanMergeSymbols(
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) {
// In TypeScript, imports are allowed to silently collide with symbols within
// the module. Presumably this is because the imports may be type-only:
//
// import {Foo} from 'bar'
// class Foo {}
//
if (existing == .import) {
return .replace_with_new;
}
// ...
}
// ...
}
pubfncan_merge_symbol_kinds<constIS_TYPESCRIPT_ENABLED:bool>(
scope_kind:Kind,
existing: symbol::Kind,
new: symbol::Kind,
) ->SymbolMergeResult {
if existing == symbol::Kind::Unbound {
returnSymbolMergeResult::ReplaceWithNew;
}
ifIS_TYPESCRIPT_ENABLED {
// In TypeScript, imports are allowed to silently collide with symbols within
// the module. Presumably this is because the imports may be type-only:
//
// import {Foo} from 'bar'
// class Foo {}
//
if existing == symbol::Kind::Import {
returnSymbolMergeResult::ReplaceWithNew;
}
// ...
}
// ...
}
任何能理解原始 Zig 代码的人,都能理解经过机械翻译的 Rust 代码。我通过检查对抗性代码审查智能体是否正确捕捉了 Zig 代码与 Rust 代码之间的差异,确保它们遵循了移植指南和生命周期指南,并且自己也手动对照 Zig 和 Rust 阅读了大量代码,从而审阅了原始的 Rust 重写 PR。
下一步计划
Bun v1.4 让 Bun 更快、更小、内存占用更少,并为团队提供了极其强大的工具,用于系统性地提升未来的稳定性:Rust 的借用检查器、Miri(在 CI 中运行于越来越多代码上)、LeakSanitizer,以及针对解析器的 24/7 覆盖引导模糊测试。虽然还有更多代码需要重构,但一切已经有了一个很好的开端。
这次 Rust 重写如果由一个对代码库有全面背景的工程师团队来做,需要一年的工作量。而通过 1 位工程师使用 Fable 并密切监控 Claude Code,我们在 11 天内就完成了从启动到所有平台测试套件 100% 通过。
如今一位工程师能完成的事情,比一年前要多得多。