Bun又用Rust重写:AI对抗工作流11天改写50万行


当Bun从Zig跳槽到Rust,借助AI对抗工作流,11天改写50万行代码,这操作把我看傻了!

Bun这个JavaScript运行时,从Go抄到Zig,现在又从Zig跳槽到Rust,整个过程只用了11天,6502次提交,峰值时每分钟产出1300行代码。

Bun体积缩小20%!内存泄漏归零!

Bun最初选择Zig的理由特别简单

Jarred在2021年4月16日写下第一行Zig代码。他当时在Hacker News上看到Zig语言参考文档,单页设计,没有花里胡哨的东西,立马被那种对底层控制和性能的执着吸引了。Zig没有隐式内存分配,没有构造函数析构函数,一切都要你手动用defer来清理。这种风格特别适合写底层基础设施。

Bun一开始就是个野心巨大的项目。JavaScript和TypeScript的转译器、压缩器、打包器,npm兼容的包管理器,Jest风格测试运行器,Node.js模块解析,HTTP和WebSocket客户端,还有fs、net、tls这些Node.js API实现。全部塞进一个工具里。

Jarred在奥克兰一个拥挤的公寓里,花了一年时间,没有大语言模型帮忙,硬生生用Zig写出了Bun的第一个版本。这种项目通常的结局就是躺在GitHub个人主页的墓地项目列表里。Zig让Bun活了下来。

现在Bun每月下载量超过2200万。Claude Code和OpenCode这些工具把Bun当成运行环境,Vercel、Railway、DigitalOcean都提供原生支持。Bun从一个个人项目长成了基础设施级别的工具。

规模大了之后,Zig的短板开始暴露

Bun v1.3.14修复了一长串崩溃和内存泄漏。node:zlib里调用reset方法时,线程池还在执行异步写入,堆内存被释放后继续使用。node:http2里,JavaScript回调触发哈希表重新哈希,内部流指针失效。UDPSocket.send里,用户代码在valueOf回调中把ArrayBuffer分离了,导致内存越界写入。crypto.scrypt里,输出缓冲区分配失败时,回调和受保护的密码缓冲区永远不会释放。

这只是冰山一角。Bun团队已经做了很多努力:给Zig编译器打补丁支持Address Sanitizer,每次提交都跑测试套件,Windows上使用ReleaseSafe构建,24小时用Fuzzilli做模糊测试,还有大量端到端内存泄漏测试。这些措施比大多数项目都强了。

但Jarred累了。每次睡觉前都在担心Bun的崩溃问题。他不怪Zig,其他Zig用户没有遇到这些问题。Bun的特殊之处在于,它要把垃圾回收的内存和手动管理的内存混在一起用。JavaScript引擎有严格的异常处理和GC规则,Zig像C一样不帮你管理内存,这种组合太容易出问题。

每个内存分配都要仔细审查:这些字节在哪里释放?怎么确保只释放一次?JavaScript异常检查正确吗?这个GC指针对保守栈扫描器可见吗?这是GC内存还是手动管理内存?

在Zig里,defer需要在每个调用点手动添加。很容易忘记清理导致内存泄漏,或者在极少触发的错误处理路径里执行两次清理导致双重释放。代码审查是主要防线,配合linter和静态分析工具做尽力而为的检查。

Bun团队考虑过用智能指针,像Rust那样把所有权明确写在类型系统里。但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风格差太远。Bun想保持简洁,不想让代码被所有权管理淹没。

Rust为什么成了最终选择

那个bug列表里,大量问题是释放后使用、双重释放、错误路径忘记释放。在安全Rust里,这些都是编译器错误。RAII风格的自动清理用Drop trait实现。编译器错误比代码风格指南的反馈循环好太多了。

但重写一个53万行Zig代码的项目,听起来像是自杀。排除注释,535496行代码,一个小团队要花整整一年。这意味着一年内不能修复bug、不能做安全更新、不能开发新功能。最稳妥的办法是机械地从Zig移植到Rust,尽可能少改变行为,用已有的测试套件验证。

Bun的测试套件是用TypeScript写的,不依赖运行时的编程语言。这个特性太关键了。

Jarred的原本计划是加Rust风格的智能指针到Bun代码库,通过风格指南强制执行来提升稳定性。但他内心其实不想这么做。自制智能指针的人机工程比Rust差,还没有Rust的保证。

然后他想到了一个疯狂的主意:花一周时间测试Anthropic的新模型能不能把Bun重写成Rust。

一开始他没抱期望。几天后,测试套件很大一部分开始通过,新Rust代码和原始Zig代码的匹配程度让他吃惊。他的态度从"值得一试"变成了"我要合并这个"。

Claude重写Bun的全过程

Jarred做这件事的方法,和很多人想象的不一样。他并没有直接说"Claude,把Bun重写成Rust,别犯错"然后祈祷。

他思考了人类会怎么做这件事。

第一个大问题:增量重写还是一次性全改?根据他之前把esbuild转译器从Go移植到Zig的经验,一次性全改更好。增量重写会引入临时代码,短中期内会很痛苦。

第二个问题:怎么让Rust版的Bun保持原来的架构、性能和功能集?怎么确保团队在重写后还能维护?答案是做一个看起来像是把Zig代码翻译成Rust的东西。等v1.4发布后,再逐渐减少unsafe使用,让代码更符合Rust惯用法。

执行层面,Jarred用了大约50个动态工作流,在Claude Code里连续跑了11天。每个工作流都是一个循环:生成移植指南,把Zig模式映射到Rust模式;机械地把每个zig文件转成rs文件;修复每个crate的编译器错误;让bun test和bun build这些子命令跑起来;让整个测试套件通过。

最关键的是对抗性审查流程。每个实现者配两个对抗性审查员。审查员只有一个工作:找出bug和代码不能工作的原因。实现者不审查,审查者不实现。

这三个bug是对抗性审查实际抓到的。第一个是异步关闭时的释放后使用加双重释放。pipe.close是异步操作,libuv保留原始句柄指针直到下一次事件循环,然后调用on_pipe_close释放内存。但pipe是Box,在这个match分支结束时就会drop。libuv拿着已经释放的内存,回调里又释放第二次。Box::leak修复了这个问题。

第二个是负时间戳的转换。对于1970年之前的文件修改时间,负数非整数时间,trunc向零取整导致nsec为负值。无效的timespec结构。用floor取整让nsec落在0到1e9之间。

第三个是color-mix里百分比的默认值处理。unwrap_or会急切求值参数,即使第一个百分比存在,第二个百分比的unwrap也会执行。被省略的百分比会触发panic。unwrap_or_else用闭包延迟求值解决了问题。

这三个bug都编译通过了,看起来都很合理。审查员是另一个Claude,在自己的上下文窗口里,只拿到diff,没有任何实现者的推理过程,专门来找茬。

工作量到底有多大

在开始写代码之前,Jarred花了3个小时和Claude讨论怎么把Zig代码的模式映射到Rust。Claude把这些讨论序列化成一个PORTING.md文档。

然后面临一个关键问题:怎么给手动管理内存的代码加上Rust生命周期?他让Claude启动一个动态工作流,分析代码库中每个结构体字段的合适生命周期。读取每个文件里每个结构体字段,追踪控制流,找出生命周期复杂的字段,提出生命周期建议,用两个对抗性审查员审查,应用反馈,序列化成LIFETIMES.tsv供其他Claude参考。

然后是试运行。在要求Claude翻译全部1448个zig文件之前,先只翻译3个。每个文件一个实现者写rs文件,两个对抗性审查员检查匹配度和移植指南遵从性,一个修复者应用建议。

实际执行时遇到了各种问题。Claude之间互相踩踏,一个跑git stash,另一个跑git stash pop,然后git reset HEAD --hard。Jarred要求编辑工作流,禁止跑任何git命令除非是提交特定文件。也不准跑cargo,不准跑任何慢命令。

峰值时,64个Claude同时在跑。4个工作树,每个16个Claude,每个工作流里1个实现、2个审查、1个应用。6502次提交,1695次提交每小时。峰值小时695次提交。

编译器错误成了工作队列。cargo check输出大约16000个错误,按crate分组,分给64个Claude。最棘手的是循环依赖问题。Zig代码库是一个编译单元,Jarred想拆成约100个crate加快编译速度,但要避免循环依赖,同时尽量减少与原始Zig实现的差异。他跑了一个工作流来分类循环依赖代码应该放哪里,再跑一个工作流执行重构。

修复循环依赖暴露了约16000个编译器错误。对一个人来说是个巨大数字,但对64个并行的Claude来说不算疯狂。

Claude有个坏习惯:遇到编译错误就"存根"函数,或者加冗长的解释性注释来证明变通方案是合理的。Jarred加了一条对抗性审查规则:如果需要段落长的注释来解释为什么变通方案没问题,代码就是错的,修复代码。

cargo check通过后,目标是让bun test能跑。然后循环修复CLI子命令的崩溃,再循环修复测试文件失败。测试套件里有内存泄漏测试、集成测试、压力测试,有些测试跑一分钟以上,有些耗尽TCP套接字,有些读写GB级数据到磁盘,有些生成约一万个进程。

从第一个CI运行到Linux全绿,失败文件从972降到23,用了一天半。Windows最后完成。最终全部6个平台全绿。

移植过程中的经典错误

debug_assert宏在发布构建中被完全擦除,包括函数调用。Zig的assert是函数,参数在任何构建中都运行。Rust的debug_assert是宏,发布构建中整个表达式被擦除。这导致热模块替换在特定React项目中失效。

Rust的bytemuck::cast_slice对奇数长度切片会panic。Zig的reinterpretSlice使用截断除法忽略多余字节。UTF-16 BOM加奇数个字节的场景导致进程崩溃。

Zig的ReleaseFast构建移除边界检查,Rust的发布构建保留。模块解析器里有个溢出块大小从2048降到了64,把850万内联文件名的上限降到了27万,真实项目触发了越界,Rust正常panic,Zig在ReleaseSafe下也会panic,但Bun只在Windows上用ReleaseSafe。

Zig的comptime格式字符串在参数替换之前就已经处理了颜色标记。Rust函数没有comptime参数,Output::pretty只看到最终字符串,在参数上也重写了标记。bun update -i打印的包名超链接被破坏。修复方案是把它变成宏。

Rust版Bun的实际改进

v1.4.0修复了128个在v1.3.14上可复现的bug,从内存泄漏到崩溃再到颜色错误的帮助文本。

Rust的Drop trait自动清理内存。在Zig里,defer需要在每个调用点手动添加。容易忘记清理导致内存泄漏,或者在错误处理路径中执行两次清理导致双重释放。Rust的Drop在值不再可访问时自动运行,用"没有隐式控制流"换来了防止常见陷阱的能力。

Bun.build在循环中调用2000次,v1.3.14里每次泄漏约3MB,总共6.7GB。v1.4.0里内存稳定在609MB。之前在Zig里尝试修复这个问题没有合并,因为缺少Drop等效机制让团队难以建立信心。

二进制体积缩小了。初始Rust重写让Windows版减少3.8MB,macOS减少5.5MB,Linux减少6.8MB。加上ICU优化和链接器折叠,总体缩小约20%。Windows从94MB降到76MB,Linux从88MB降到70MB。

Rust的LLVM IR会为栈变量生成llvm.lifetime.start和llvm.lifetime.end,让LLVM复用栈空间槽位。Toml解析器和其他递归下降解析器使用更少栈空间。之前团队手动把特别大的函数拆分成小函数来绕过Zig的栈空间问题。

Rust支持C/C++和Rust之间的跨语言链接时优化,可以在编程语言之间内联。HTTP吞吐量提升2.8%到4.8%,next build提升4.5%,tsc -b提升4.7%。

生产环境验证和后续工作

Prisma Compute公测版跑在Rust重写的Bun上。之前的Zig版Bun有内存泄漏和连接池在VM暂停恢复后无法恢复的问题。Rust版完美处理了这些故障模式。

Claude Code v2.1.181开始使用Rust版Bun,Linux启动速度提升10%,几乎没人注意到。无趣就是好事。

合并Rust重写后,团队完成了11轮Claude Code Security安全审查。添加了24/7覆盖引导的模糊测试,覆盖JavaScript、TypeScript、JSX、CSS、JSON5、JSONC、TOML、YAML、Markdown、INI、Bun Shell脚本、semver范围、patch文件和CSS颜色。模糊器自动把发现的bug发给Claude提交PR,人类审查PR。目前解析器被执行了1000亿次,产生了大约15个PR。

约4%的Rust代码在unsafe块里,约13000个unsafe关键字分布在约27000行里,占78万行代码的4%。其中78%的unsafe块只有一行,指向来自C++的指针或调用C库。这个比例会随着从忠实Zig移植向惯用Rust重构而下降,但因为要继续使用JavaScriptCore这些C/C++库,unsafe会一直比纯Rust项目多。

Jarred算了一笔账:预合并阶段消耗了59亿未缓存输入token,6.9亿输出token,720亿缓存输入token读取。按API定价约16.5万美元。人工完成这项工作,需要3个对代码库有完整上下文理解的工程师干一年,期间无法改善Node.js兼容性、修复bug和漏洞、实现新功能。Bun团队永远不会做这种重写。现实的替代方案是什么都不做,继续修复文章开头那些bug。

这件事到底意味着什么

一个工程师带着Claude Code的对抗性审查工作流,11天完成了3个工程师一年的工作。内存泄漏归零,体积缩小20%!

Jarred的角色从写代码变成了设计工作流、监控输出、发现问题时修改流程而不是手工修复代码。

6502次提交,每次提交都被至少两个对抗性审查员检查过。1695次提交每小时,峰值695次提交在一个小时内完成。这些数字背后不是自动化的胜利,而是一种新的工作方式。

Zig让Bun从0到1成为可能。Rust让Bun从1到100变得更稳定。编程语言的选择曾经是一个单向门,现在有了LLM辅助重写,这个门在某种程度上变成了双向的。

但Jarred最核心的观点是:一个人能做的事情比一年前多太多了。这可能是整个故事最值得记住的结论。

一句话总结:Bun用11天从Zig跳到Rust,16万美元API费用换3个工程师一年的工作量,稳定性和性能双双提升,这也许就是软件工程的新常态。


原文出处: https://bun.com/blog/rewriting-bun-in-rust