将Bun从Zig重写为Rust的实践记录
披露: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客户端
- Node.js API实现,包括
fs、net、tls等数十个模块
最初版本的Bun由我独自在奥克兰一间狭小公寓里,用Zig耗时一年完成——那时大语言模型(LLM)尚未普及。这类野心勃勃的项目,往往难逃沦为GitHub个人主页上"废弃项目"的命运,而Zig让Bun得以存活。若非Zig,我绝无可能在一年内完成如此多的功能。
如今,Bun CLI的月下载量已突破2200万次。Claude Code、OpenCode等热门工具均选择Bun作为运行时,Vercel、Railway、DigitalOcean等平台也提供了对Bun的官方支持。
但Bun的宏大定位也带来了稳定性挑战。以下是我们在Bun v1.3.14版本中修复的部分典型bug:
- 当线程池中的异步
.write()操作仍在进行时,调用zlib、Brotli或Zstd流的.reset()方法,会导致node:zlib模块出现堆内存释放后使用(heap-use-after-free)崩溃 - 当
onerror回调函数在原生句柄上先执行重入式write()再调用close()时,node:zlib模块会出现内存释放后使用崩溃 - 当重入式JS回调(如超时监听器、选项getter或写入回调中的
session.request())触发哈希表重新哈希,导致内部流指针失效时,node:http2模块会出现内存释放后使用崩溃 - 在
UDPSocket.send()和sendMany()中,用户代码在valueOf()或toString()回调中可能会在负载捕获与实际发送之间分离ArrayBuffer,引发内存释放后使用问题 - 当
valueOf回调在参数强制转换过程中分离或调整底层ArrayBuffer大小时,Buffer#copy和Buffer#fill会出现崩溃与越界读取 - 当用户JS回调在迭代过程中改变套接字连接状态时,
UDPSocket.sendMany()会出现堆内存越界写入 - 当输出缓冲区分配失败时,
crypto.scrypt中的回调函数及受保护的密码/盐缓冲区永远不会被释放,导致内存泄漏 SSLWrapper.init在错误路径中会泄漏通过strdup复制的密码tlsSocket.setSession()每次调用都会泄漏一个SSL_SESSION对象(约6.5 KB),原因是d2i_SSL_SESSION后未调用SSL_SESSION_free- 调用
.close()后,fs.watch()的监视器永远不会被垃圾回收,这是由于引用计数下溢导致每个监视器被永久标记为GC根节点 - 当
background-clip属性带有厂商前缀且包含多层背景时,CSS解析器会出现双重释放崩溃 DuplexUpgradeContext永远不会被释放——每次调用tls.connect({ socket: duplex })都会产生完整的内存泄漏MessageEvent存在竞态条件崩溃:GC标记线程在BroadcastChannel或MessagePort并发访问期间,可能会观察到m_data中的变体损坏
我们本可以一直这样逐个修复bug,但依赖Bun的用户值得我们做得更好——系统性地杜绝此类问题复发。为此我们采取了以下措施:
- 为Zig编译器添加Address Sanitizer支持,每次提交代码都会用ASAN运行测试套件
- 在Windows平台发布经过安全检查的ReleaseSafe构建版本
- 使用Fuzzilli(V8与JavaScriptCore采用的JavaScript引擎模糊测试工具)对Bun的运行时API进行全天候模糊测试
- 编写了大量端到端内存泄漏测试
这些举措已经超出了许多项目的常规做法。
但长长的bug修复清单仍让人沮丧,我也早已厌倦了因担心Bun崩溃而难以入眠。这并非Zig的问题——其他Zig用户并未遇到类似问题,只是将垃圾回收(GC)与手动内存管理混合使用的场景极为罕见,没有语言专门针对这种情况进行设计。若非Zig,Bun走不到今天,我始终对此心怀感激。直到不久前,对于Bun这类项目而言,编程语言的选择仍是单向的、不可逆转的决定。
JavaScript是一门垃圾回收语言,JavaScriptCore、V8等现代JavaScript引擎对异常处理与垃圾回收有着严格的规则。而Zig与C一样,不提供自动内存管理——这也是许多项目选择Zig的重要原因。Zig没有构造函数/析构函数,大多数清理操作需要在每个调用点通过defer关键字显式编写。
对于Bun而言,正确处理垃圾回收值与手动管理值的生命周期,一直是稳定性问题的主要来源——多数表现为小内存泄漏,偶尔会引发崩溃。每一次内存分配都必须经过细致审查:这些内存何时释放?如何确保只释放一次?是否正确处理了JavaScript异常?这个垃圾回收指针是否会被保守式栈扫描器识别到?这是垃圾回收内存还是手动管理内存?
稳定性问题的处理原则是"越早发现越好"。模糊测试在代码合并后进行,CI测试在代码推送后运行,运行时安全检查与地址 sanitizer则在代码执行时生效(希望是在开发阶段、CI之前)。
减少此类问题的常见方法之一,是确保需要清理的代码始终被精确执行一次。Zig设计为一门无隐藏控制流的简单语言,因此更倾向于使用显式的defer关键字在作用域结束时执行清理代码,而非C++的隐式~Destructor或Rust的隐式Drop。
| 语言 | 清理机制 |
|---|---|
| Zig | defer、errdefer |
| C++ | ~Destructor、&&Move |
| Rust | Drop |
对于Zig代码,我们何时该执行清理代码?如果将同一个*T传递给多个不同函数,如何判断它何时不再被访问、可以被清理?当某些函数在调用结束后仍需要引用该内存时,又该如何处理?我们目前的解决方案是多种方式混合:
- 内存池生命周期:明确内存可访问的作用域(例如解析器状态不会超出调用函数,因此AST节点适合用这种方式管理)
- 引用计数
- 人工仔细检查
许多项目会通过风格指南来回答这类问题。Zig生态中的TigerBeetle项目的TigerStyle,以及谷歌长达31000字的C++风格指南都是典型例子。但风格指南的难点在于执行:如何确保所有人都遵守?过去的解决方案是代码审查,再结合代码检查工具与静态分析器尽最大努力执行。
为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);
// ...
}
Bun约20%的代码由C++编写,还嵌入了多个C/C++库:
- JavaScriptCore:驱动Safari的JavaScript引擎
- uWebSockets & usockets:HTTP/WebSocket服务器与事件循环
- lshpack & lsquic:
HPACK与HTTP/3库 - BoringSSL:谷歌基于OpenSSL的分支版本
- SQLite
改用C++对Bun而言也是合理选择:我们可以使用构造函数与析构函数,删除大量extern "C"包装代码。
但即便如此,我们仍需依赖代码审查来执行风格指南,即便有ASAN加持,内存损坏与泄漏问题仍会发生。
之前列出的bug中,很大一部分是内存释放后使用、双重释放,以及错误路径中"忘记释放"的问题。在安全Rust中,这些都是编译错误,通过类似RAII的自动Drop清理机制就能避免。相比风格指南,编译错误的反馈循环显然更高效。
通常来说,重写代码是个糟糕的主意。不含注释的话,Bun的Zig代码共有535496行。若改用其他语言重写,一个小型工程师团队需要一整年时间,期间还得暂停bug修复、安全补丁与功能开发。最稳妥的可交付方案,是将Zig代码机械移植到Rust,尽可能减少行为变更,并沿用现有的测试套件。
幸运的是,Bun的测试套件由TypeScript编写,不依赖运行时的编程语言。
但整整一年不推出任何面向用户的更新,显然不现实。因此,通过代码风格规范来解决稳定性问题,曾是我们的最佳方案——我们甚至已计划在Bun代码库中添加类Rust的智能指针。
但说实话,我并不想这么做。自研智能指针的 ergonomics(人机工程学特性)远不如Rust,还无法提供任何保障。
那换个思路:我花一周时间测试Anthropic的新模型能否将Bun重写为Rust,结果会怎样?
起初我没抱太大期望。但几天后,测试套件的通过率大幅提升,新生成的Rust代码与原Zig代码库高度匹配。我的态度从"值得一试"转变为"我要把这个合并进去"。
这件事很容易搞砸。比如直接让Claude"把Bun重写成Rust,别出错"然后听天由命——我并没有这么做。
不妨先思考一下,如果由人来做这件事会怎么操作。第一个核心问题是:
增量重写?还是一次性全部重写?
根据我之前将esbuild转译器从Go移植到Zig的经验(当时还没有LLM),一次性全部重写效果更好。增量重写会引入临时代码,虽然希望最终能删除,但在中短期内会带来不小的麻烦。
第二个核心问题:具体怎么做?
如何让Rust版Bun与原版保持一致,拥有相同的架构、性能与功能集,同时又能利用Rust的借用检查器等语言特性?如何确保团队在重写后仍能维护代码?
先做"机械转译",让Rust代码看起来像是Zig代码的转译产物。等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议题等),result是为解决问题编写的代码,代码评审者会检查变更是否存在回归或正确性问题,最后由开发者处理评审反馈。
我通过Claude Code运行约50个动态工作流,耗时11天完成了Bun的Rust重写。
每个动态工作流都是类似上述的循环,负责不同任务:
- 生成移植指南,将Zig模式与类型映射到Rust模式与类型
- 按照PORTING.md和LIFETIMES.tsv,将所有
.zig文件机械移植为.rs文件 - 修复所有 crate 的编译错误
- 让
bun test、bun build等子命令正常工作 - 确保Bun整个测试套件的所有测试通过
- 多次大规模重构与代码清理
在这11天的大部分时间里(以及之后),我持续监控这些工作流——手动检查输出以发现问题,同时提示Claude调整循环来修复问题。
面对一个新增超百万行代码的PR,该如何评审?如何建立足够的信心,负责任地合并大量由LLM生成的代码?
答案是:语言无关的测试套件(包含百万级断言)、对抗性代码评审,以及当问题出现时,修复生成代码的流程而非手动修改代码。
对抗性评审,是让Claude在独立的上下文窗口中,尽可能找出变更可能引入的bug或无法正常工作的原因。
拆分上下文窗口
对于人类而言,代码评审者通常不是代码作者。代码作者希望代码尽快合并,这可能导致他们在代码未完全就绪时就急于提交。
Claude也是如此。编写代码的Claude希望代码被接受,而评审代码的Claude则希望找出代码中的问题。
因此我们采用"1个实现者 + 每个实现者搭配2名及以上对抗性评审者"的模式。评审者的唯一任务是找bug、找出代码无法工作的原因;实现者不参与评审,评审者不参与实现。
如果你要做一件庞大且成本高昂的事,先降低风险能节省大量时间与资金。
在编写任何代码之前,我花了约3小时与Claude讨论如何将Zig代码库中的模式紧密映射到Rust。Claude将讨论内容整理成PORTING.md文档,该文档后来被发布到Hacker News。
接下来的问题是:如何为手动管理内存的代码添加Rust生命周期?
我向Claude提出了如下需求:
我:启动一个动态工作流,分析代码库中每个结构体字段的正确生命周期。该工作流需要读取每个文件中的所有结构体字段并跟踪控制流。首先找出需要用Rust表达复杂生命周期的结构体字段,然后为该字段提议一个生命周期,再让2个对抗性评审者评审这个生命周期,最后根据反馈修改并将结果序列化到LIFETIMES.tsv中,供其他Claude使用。
随后,我们对PORTING.md和LIFETIMES.tsv进行了一轮对抗性评审,以解决冲突建议并再次检查所有内容,我也手动通读了一遍。
在让Claude翻译全部1448个.zig文件之前,我先从3个文件开始试点。对于这3个文件,1个实现者编写新的.rs文件,2个对抗性评审者检查.rs文件是否与.zig文件行为一致,以及是否遵循PORTING.md和LIFETIMES.tsv的规范,最后由1个修复者应用评审建议。
之后我让Claude对所有1448个.zig文件执行该工作流,但大约2分钟后就出问题了:一个Claude在提交前执行了git stash,另一个执行了git stash pop,接着又有Claude执行git reset HEAD --hard——它们互相干扰了!如果让每个Claude使用独立的工作树,又会因Bun的Git仓库过大而耗尽磁盘空间,而且最终所有变更仍需要一起编译、协同工作。
于是我让Claude修改工作流,禁止执行git stash、git reset等非逐文件提交的Git命令,也禁止执行cargo等耗时命令,所有慢命令一律禁用。
之后Claude恢复了工作流,这次成功了!但速度太慢,于是我将工作流拆分为4个分片,每个分片使用独立的工作树(共4个工作树),每个分片运行16个Claude进行提交和推送。
得益于并行化处理与前期准备工作,Claude的峰值代码生成速度约为每分钟1300行。每一行代码都经过两个独立的对抗性评审者(同样是Claude)的评审,并在提交前经过一轮修复。当然,此时所有代码还无法正常运行。
注意到时间不一致的问题了吗?我忘记调高EC2实例的默认IOPS了。一个缓慢的grep命令就导致磁盘读写冻结了数分钟。
生成所有代码后,我让Claude编写一个工作流来修复所有编译错误。我们逐个 crate 处理。
最棘手的一类错误是循环依赖。
我们的Zig代码库是一个编译单元(实际上是一个crate)。我希望将新的Rust代码库拆分为约100个crate以加快编译速度,但这需要避免循环依赖,同时尽量减少与原Zig实现的差异。我在启动Rust重写前提交的PR不足以解决这个问题。于是我没有从头开始,而是运行另一个工作流来分类处理存在循环依赖的代码,并记录下来——随后再运行一个工作流进行重构。
解决循环依赖后,我们发现了约16000个编译错误。这对一个人来说是天文数字,但对64个并行运行的Claude而言并非难事。
为了最大化并行性,工作流逐个遍历每个crate:
- 对每个crate,运行
cargo check,按文件分组保存错误信息 - 修复该crate内的所有编译错误
- 2个对抗性评审者评审该crate的变更
- 1个修复者应用修改
为避免Claude互相干扰,cargo check仅在最开始运行一次,其他环节同样要等到最后再执行Git命令。
另一个挫折
Claude将"让所有crate编译通过"理解为"为存在编译错误的函数添加存根",还开始添加冗长的注释来解释变通方案。于是我为对抗性评审者添加了一条规则:
如果需要用一段话来证明变通方案的合理性,说明代码本身有问题——请修复代码。
修改提示后几小时,这类问题就不再出现了。
模型总喜欢提到"冒烟测试"
cargo check通过后,下一步是让代码编译并运行bun --version。此时出现了链接错误,随后启动时立即崩溃。
接下来的目标是让bun test <file>正常运行。一旦成功,我们就能开始运行测试了!于是又启动了一个工作流,逐个处理Bun CLI子命令:
- 将每个失败的栈跟踪信息及其对应的子命令保存到文件中
- 按子命令分组处理每个失败的栈跟踪,由1个Claude负责修复
- 2个对抗性评审者评审修复方案
- 1个修复者应用评审建议
这个工作流循环处理测试文件:
随机选取约100个测试文件,按代码库中的文件夹分配到4个工作树中。对于每个失败的测试,保存栈跟踪与错误信息,由1个实现者提出修复方案,2个对抗性评审者评审,最后由1个修复者应用修改。
更多挫折
我们的测试套件包含大量内存泄漏测试,还有一些集成测试耗时超过一分钟——例如,一个测试会运行next dev并检查热模块重载能否连续100次检测到变更。其中一些测试在调试构建中会超时。
我们还有压力测试,比如耗尽机器上的最大TCP套接字数、读写数GB磁盘数据,以及启动约10000个进程。
仅靠"请谨慎操作"不足以隔离这些测试,于是我们使用systemd-run(cgroups)来限制内存与CPU使用,并隔离pid命名空间。即便如此,机器仍多次因磁盘空间耗尽而崩溃。
首次CI运行两天后,失败的测试文件从972个减少到23个。一天半后,Linux平台的测试全部通过——我第一次觉得,这次Rust重写真的能成功。
之后到合并代码前的工作就相对顺利了:一个工作流循环修复各平台的CI测试失败,直到所有测试通过;还有多个工作流负责Windows相关的代码清理、代码去重、减少unsafe代码使用,以及整体代码优化。
当所有平台的CI测试套件100%通过(且我手动验证测试确实在运行而非被跳过)后,我在本地运行了一系列命令进行测试——然后按下了合并按钮。
合并到main分支并不等同于发布正式版本。此时我已足够自信推进并完成重写,但还没到可以发布的程度。
峰值时期,我们同时运行4个这样的工作流,每个工作流使用独立的工作树,每个工作流包含16个Claude,总计约64个Claude并行运行。
0测试被跳过或删除
耗时11天(5月3日启动→5月14日合并)· 6778次提交
| 平台 | 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天(否则我得自己编写一套复杂的管理工具才能实现)。
合并Rust移植版后,我们完成了Claude Code Security的11轮安全评审,并已处理所有评审发现的问题。
我们还为Bun中的所有解析器添加了全天候覆盖引导模糊测试,包括JavaScript、TypeScript、JSX、CSS、JSON5、JSONC、TOML、YAML、Markdown、INI、Bun Shell脚本、语义化版本范围、.patch文件和CSS颜色。模糊测试工具会自动将发现的bug发送给Claude,由其提交包含复现与修复代码的PR,再由人工评审这些PR。截至目前,解析器已被执行1000亿次,生成了约15个PR。
撰写本文时,Bun的Rust代码中约有4%位于unsafe块中(约78万行代码里,有2.7万行包含unsafe关键字,共约1.3万个unsafe标记),其中78%的unsafe块仅一行代码——要么是来自C++的指针,要么是对C库的一次调用。随着我们从忠实的Zig移植版(没有可通过grep查找的unsafe关键字)逐步重构为符合Rust惯用风格的代码,这个比例预计会下降。但由于我们仍会使用JavaScriptCore等C/C++库,其unsafe代码比例仍会高于纯Rust项目。
本次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)功能出现故障:Cannot destructure property 'isLikelyComponentType' of 'k'。调试构建则能正常工作。#30678
奇数长度切片
Bun的Zig工具函数reinterpretSlice(u16, bytes)(早于支持切片的内置转换)使用@divTrunc并忽略末尾的奇数字节。而bytemuck::cast_slice遇到这种情况会直接panic。当Blob.text()处理UTF-16字节序标记后跟随奇数个字节时,不再返回字符串而是导致进程崩溃。我们最终恢复了忽略奇数字节的逻辑:&buf[..buf.len() & !1]。#31188
边界检查
在macOS与Linux平台,我们使用ReleaseFast模式编译Zig代码,该模式会移除边界检查;而Rust的发布构建会保留边界检查。
Bun的模块解析器会将长文件名存入一个全局列表,超出容量时会溢出到块中。原Zig代码将每个块的大小设置为count / 4,即2048。移植时我们留下了一个占位符:
/// ... 因此在第二阶段将每个实例的值传递过来之前,使用一个非零的替代值。
pub const BSS_OVERFLOW_BLOCK_SIZE: usize = 64;
这将可存储的文件名上限从840万个降至270272个,而实际项目会达到这个上限,导致我们从Zig移植过来的ptrs[4095]越界错误变得可触发。Rust会直接panic,而Zig如果使用ReleaseSafe模式(我们仅在Windows平台使用)也会panic。#31503
编译期格式字符串
Output.pretty会将<r>和<d>颜色标记重写为ANSI转义序列。在Zig中,fmt是编译期(comptime)函数,因此标记会在参数替换前被处理掉;而Rust函数没有编译期参数,因此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被当作文本打印出来。

在Rust中,必须将其实现为宏:bun_core::pretty!("<r>{}<r>", hyperlink)。#30693
截至目前,Bun v1.4.0已修复了128个在v1.3.14中可复现的bug,涵盖内存泄漏、崩溃、帮助文本颜色错误等各类问题。
Rust提供了强大的语言级内存清理工具:Drop。当实现Drop trait后,值离开作用域时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()调用都会泄漏数MB内存——解析后的源代码文本与AST符号表会在构建完成后继续存在。
// 在一个进程中重复2000次打包同一个包含60个模块的项目
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。
在初始缩减的基础上,团队还通过链接器优化(如相同代码折叠)、移除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 |
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.start和llvm.lifetime.end内在函数,这让LLVM可以复用栈空间插槽,从而大幅减少嵌套作用域的大型函数的栈空间使用。
此前,我们通过将特别大的函数重构为多个小函数,手动解决了一个公开问题。
Rust支持C/C++与Rust之间的跨语言链接时优化,这意味着跨编程语言的内联成为可能(这太酷了!)。
我们在Linux x64平台(EC2,Xeon Platinum 8488C)上对Bun v1.3.14与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 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的版本。目前已推出预览版,欢迎反馈问题:
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 {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 {Foo} from 'bar'
// class Foo {}
//
if existing == symbol::Kind::Import {
return SymbolMergeResult::ReplaceWithNew;
}
// ...
}
// ...
}
任何理解原Zig代码的人,都能理解这段机械转译的Rust代码。我在评审最初的Rust重写PR时,主要检查对抗性代码评审代理是否正确捕捉了Zig与Rust代码之间的差异,是否确保遵循了移植指南与生命周期指南,同时也手动对比阅读了大量代码。
Bun v1.4版本更快、体积更小、内存使用更低,还为团队提供了系统性提升稳定性的强大工具:Rust的借用检查器、Miri(CI中已覆盖越来越多的代码)、LeakSanitizer,以及针对解析器的全天候覆盖引导模糊测试。虽然仍有更多重构工作要做,但我们已经有了一个很棒的开端。
如果由熟悉代码库的工程师团队手动完成这次Rust重写,需要一年时间。而借助Fable模型并由一名工程师密切监控Claude Code,我们从启动到所有平台测试套件100%通过,仅用了11天。
如今,一名工程师能完成的工作,早已远超一年前。