用Rust重写Bun

Hacker News Top 产品

摘要

Bun,这个JavaScript运行时和工具链,正在从Zig重写为Rust,以提高内存安全性和稳定性,解决一系列use-after-free和内存泄漏错误。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/07/09 07:39

# 在 Rust 中重写 Bun 来源:https://bun.com/blog/bun-in-rust 声明:Bun 于 2025 年 12 月被 Anthropic 收购。我和 Bun 团队的其他成员现在都在 Anthropic 工作。我在 Rust 重写过程中大量使用了 Claude Fable 5 的预发布版本。 Bun 最初是将 esbuild 的 JavaScript 和 TypeScript 转译器从 Go 逐行移植到 Zig。我在 2021 年 4 月 16 日写下了我的第一行 Zig 代码 (https://github.com/ziglang/zig/issues/8575)。在看到 Hacker News 上只有一页的 Zig 语言参考 (https://ziglang.org/documentation/master/) 后,我被其底层控制和性能方面的精心设计所吸引,于是押注了 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.13.14 中一些 bug 的样本:- 当在 `node:zlib` 中对 zlib、Brotli 或 Zstd 流调用 `.reset()`,而线程池上仍有异步的 `.write()` 在进行时,发生堆释放后使用崩溃。- 在 `node:zlib` 中,当 `onerror` 回调发出一个重入的 `write()` 后跟 `close()` 到原生句柄时,发生释放后使用崩溃。- 在 `node:http2` 中,当重入的 JS 回调(例如在超时监听器、options getter 或写入回调中的 `session.request()`)触发哈希表重哈希,导致内部流指针失效时,发生释放后使用崩溃。- 在 `UDPSocket.send()` 和 `sendMany()` 中的释放后使用问题:当用户代码在 `valueOf()` 或 `toString()` 回调中可能在捕获负载和实际发送之间分离 `ArrayBuffer`。- 在 `Buffer#copy` 和 `Buffer#fill` 中,当 `valueOf` 回调在参数强制转换期间分离或调整底层 `ArrayBuffer` 的大小时,发生崩溃和越界读取。- 在 `UDPSocket.sendMany()` 中,当 socket 的连接状态在遍历过程中因用户 JS 回调而改变时,发生堆越界写入。- 在 `crypto.scrypt` 中的内存泄漏:当输出缓冲区分配失败时,回调以及受保护的密码/盐缓冲区从未被释放。- `SSLWrapper.init` 在错误路径上泄漏了 strdup 过的密码短语。- 在 `tlsSocket.setSession()` 中的内存泄漏:由于在 `d2i_SSL_SESSION` 之后缺少 `SSL_SESSION_free`,每次调用都会泄漏一个 `SSL_SESSION`(每次调用约 6.5 KB)。- 内存泄漏:在 `fs.watch()` 中,调用 `.close()` 之后观察者从未被垃圾回收,原因是引用计数下溢导致每个观察者被永久固定为 GC 根。- 在 CSS 解析器中,当 `background-clip` 带有厂商前缀和多层背景时,发生双重释放崩溃。- `DuplexUpgradeContext` 从未被释放 —— 每次 `tls.connect({ socket: duplex })` 都会完全泄漏一次。- `MessageEvent` 中的竞态条件崩溃:当 GC 标记线程在从 `BroadcastChannel` 或 `MessagePort` 并发访问 `m_data` 时,可能观察到其中的撕裂变体。我们本可以继续逐个修复这些 bug,但为了对得起信任我们的用户,我们必须做得更好,并系统性地防止这类 bug 再次发生。### 我们已经采取的措施 (https://bun.com/blog/bun-in-rust#what-we-were-already-doing)- 我们对 Zig 编译器打了补丁,添加了 Address Sanitizer 支持。我们在每次提交时都会用 ASAN 运行测试套件。- 我们在 Windows 上发布启用 Zig 安全检查的 ReleaseSafe 版本。- 我们使用 Fuzzilli (https://github.com/googleprojectzero/fuzzilli)(V8 和 JavaScriptCore 使用的 JavaScript 引擎 fuzzer)全天候对 Bun 的运行时 API 进行模糊测试。- 我们有大量的端到端内存泄漏测试。这些措施比许多项目做得都要多。## 只要非常聪明,不犯错误就行?(https://bun.com/blog/bun-in-rust#just-be-really-smart-and-don-t-make-mistakes) 我们的 bug 修复清单让人感觉很差劲,我也厌倦了晚上担心 Bun 崩溃而睡不着。我并不责怪 Zig —— 其他使用 Zig 的人并没有我们遇到的这些 bug,而且将 GC 与手动管理的内存混合使用对软件来说是一件相当罕见的需求,以至于没有哪种语言真正为此设计。如果没有 Zig,我们不可能走到今天,我将永远感激。直到最近,对于像 Bun 这样的项目来说,编程语言的选择还是一个单向的决定。JavaScript 是一种垃圾回收语言,而像 JavaScriptCore(和 V8)这样的现代 JavaScript 引擎在异常处理和垃圾回收方面有严格的规则。Zig 像 C 一样,不会为你管理内存,对于许多项目来说,这是选择 Zig 的一个很好的理由。Zig 没有构造函数/析构函数,大多数清理工作预期在每个调用点显式地使用 `defer` 来编写。对于 Bun 来说,正确处理垃圾回收值和手动管理值的生命周期一直是稳定性问题的主要来源 —— 最常见的是小的内存泄漏,偶尔是崩溃。每一个内存分配都必须经过仔细审查。这些字节在哪里被释放?我们如何确保它只被释放一次?我们是否正确检查了 JavaScript 异常?这个垃圾回收指针是否对保守的栈扫描器可见?这是垃圾回收内存还是手动管理的内存?对于稳定性问题,尽早知晓是最好的。模糊测试发生在代码合并之后。CI 在代码推送时运行。运行时安全检查和 Address Sanitizer 在代码运行时(希望是在开发阶段,CI 之前)运行。减少这类问题的一种常见方法是确保需要清理的代码始终精确执行一次。Zig 被设计为一种简单的语言,没有隐藏的控制流,因此它倾向于使用显式的 `defer` 关键字在作用域结束时运行代码,而不是 C++ 的隐式 ~Destructor 或 Rust 的隐式 `Drop`。语言 清理机制Zig `defer`, `errdefer` C++ ~Destructor, &&Move Rust `Drop` 对于 Zig 代码,究竟应该在何时运行清理代码?如果我们把同一个 `*T` 传递给许多不同的函数,我们如何知道它何时不再可访问并可以被清理?当某些函数需要在函数调用后继续引用该内存时,又该如何处理?我们目前的方法是混合使用:- arena 生命周期,其中可访问的作用域是明确的(解析器状态不会逸出调用函数,因此 AST 节点在那里是一个不错的选择)- 引用计数- 非常仔细地关注许多项目通过风格指南来回答这类问题。TigerBeetle 的 TigerStyle (https://tigerstyle.dev/) 是 Zig 中的一个例子,而 Google 的 31,000 字的 C++ 风格指南 (https://google.github.io/styleguide/cppguide.html) 是另一个例子。风格指南的挑战在于强制执行。你如何确保风格指南得到遵守?历史上,代码审查是答案,并通过 linter 和静态分析器进行尽最大努力的强制执行。对于 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); // ... } `` ## 那 C/C++ 呢?(https://bun.com/blog/bun-in-rust#what-about-c-c) Bun 大约 20% 的代码是用 C++ 编写的,并且 Bun 嵌入了一些 C/C++ 库:- JavaScriptCore —— 驱动 Safari 的 JavaScript 引擎- uWebSockets & usockets —— 我们的 HTTP/WebSocket 服务器和事件循环- lshpack & lsquic —— `HPACK` 和 HTTP/3 库- BoringSSL —— Google 的 OpenSSL 分支- SQLite C++ 而不是 Zig 对 Bun 来说可能是一个合理的选择。我们可以获得构造函数和析构函数。我们可以删除大量 `extern "C"` 包装代码。但是,我们仍然需要依赖通过代码审查来强制执行的风格指南,即使有 ASAN,内存损坏和内存泄漏仍然会发生。## 为什么选择 Rust?(https://bun.com/blog/bun-in-rust#why-rust) 上面列表中的大量 bug 是释放后使用、双重释放以及错误路径中的“忘记释放”。在安全的 Rust 中,这些是编译器错误,并且有 RAII 式的自动清理(`Drop`)。编译器错误是比风格指南更好的反馈循环。从历史上看,重写是一个糟糕的主意。不包括注释,Bun 有 535,496 行 Zig 代码。用另一种语言重写需要一个小型工程师团队整整一年的时间。这意味着在那段时间内冻结 bug 修复、安全修复或功能开发。获得可交付成果的最小风险方法是进行从 Zig 到 Rust 的机械移植,行为更改最少,并使用我们已经在用来测试 Bun 的完全相同测试套件。幸运的是,Bun 自己的测试套件是用 TypeScript 编写的,这意味着它不依赖于运行时的编程语言。一年没有用户可见的影响并不是我们可以考虑的现实选择。因此,通过代码风格来强制执行以解决稳定性问题是我们最好的选择,也是我们在向 Bun 代码库添加受 Rust 启发的智能指针 (https://github.com/oven-sh/bun/blob/3a79bd746b11601c9db970b608c73f0b9f96ac81/src/ptr/shared.zig#L569) 时的计划。但老实说,我不想这样做。自制的智能指针比 Rust 提供了更差的用户体验,且没有任何保证。那么,如果我花一周时间测试 Anthropic 的新模型能否用 Rust 重写 Bun 呢?起初,我并没有期望它能成功。几天后,测试套件的高比例开始通过,我看到了新的 Rust 代码与原始 Zig 代码库的匹配程度。我的看法从“这值得一试”变成了“我要合并这个”。## Claude,用 Rust 重写 Bun。(https://bun.com/blog/bun-in-rust#claude-rewrite-bun-in-rust) 有很多方法可以把这件事搞砸。例如,提示 Claude “用 Rust 重写 Bun。不要犯任何错误。”然后祈祷它奏效,这不是我所做的。想想一个人会怎么做。第一个大问题是:增量重写?还是一次性全部重写?根据我最初(在没有 LLM 的情况下)将 esbuild 的转译器从 Go 移植到 Zig 的经验,一次性全部重写更好。增量重写会引入你希望最终被删除的临时代码,并且在短期内会很痛苦。第二个大问题:如何做?我们如何让用 Rust 编写的 Bun 保持和以前一样的 Bun,具有相同的架构、性能和功能集,同时还能获得 Rust 的语言特性(如借位检查器)?我们如何确保团队在重写后仍能维护它?进行重写,使其看起来就像我们将 Zig 代码转译成了 Rust 一样。在 Bun v1.4 发布后,我们可以逐步重构以减少 `unsafe` 的使用,并使其看起来更像地道的 Rust。这就是仅有的两个大问题。其他的都是战术问题。## 编写和审查代码的循环 (https://bun.com/blog/bun-in-rust#loops-that-write-review-code) 软件工程师的日常工程工作通常可以过度简化成循环。`` // 伪代码,不是真正的代码: 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 issue 等)。`result` 是你为修复它而编写的代码。代码审查者 `review` 更改以检查回归和正确性。然后你处理反馈。我在大约 11 天内,使用 Claude Code 中约 50 个动态工作流连续运行,重写了 Bun 的 Rust 版本。每个动态工作流都是这样的一个循环——一个用于以下任务的工作流:- 生成一个移植指南,将 Zig 模式和类型映射到 Rust 模式和类型- 机械地将每个 `.zig` 文件移植到 `.rs` 文件,匹配 PORTING.md 和 LIFETIMES.tsv- 修复每个 crate 的编译器错误- 使 `bun test` 或 `bun build` 等子命令正常工作- 让 Bun 整个测试套件中的所有测试通过- 几个大型重构和清理工作在这 11 天的大部分时间里(以及之后),我监控着工作流 —— 手动读取输出以检查问题和 bug,并提示 Claude 编辑循环以修复问题。如何审查一个添加了超过 100 万行代码的 PR?如何开始建立必要的信心,以负责任地合并大量由 LLM 生成的代码?一个语言无关的、拥有一百万断言的测试套件、对抗性代码审查,以及当出现问题时,修复生成代码的过程而不是手动修复代码本身。### 对抗性审查 (https://bun.com/blog/bun-in-rust#adversarial-review) 对抗性审查要求 Claude(在一个单独的上下文窗口中)详尽地找出更改可能造成 bug 或无法工作的原因。#### 拆分上下文窗口通常,对于人类来说,审查代码的人并不是编写代码的人。编写代码的人想要合并代码,这可能会使他们的行为偏向于在准备好之前就发布。Claude 也是如此。编写代码的 Claude 希望代码被接受。审查代码的 Claude 想要找出代码中的问题。1 个实现者,每个实现者对应 2 个或更多对抗性审查者。审查者唯一的任务:找出 bug 以及代码无法工作的原因。实现者不进行审查。审查者不进行实现。✻ claude code · 动态工作流 对抗性审查 对抗性审查在合并之前捕获的众多 bug 中的 3 个 bug 1 of 3 · 异步关闭 ✻ claude 实现者 其上下文:原始 .zig、移植计划、其自身的推理 ✻ claude 对抗性审查者 其上下文:仅 diff。被指示假定代码是错误的。 ✻ src/runtime/api/bun/js_bun_spawn_bindings.rs · 对于 stdio in [spawned_stdout, spawned_stderr] 编译通过 { match stdio { StdioResult::Buffer(mut pipe) => { // pipe: Box — 交给 libuv 来关闭 pipe.close(Subprocess::on_pipe_close) } StdioResult::Fd(fd) => fd.close(), StdioResult::Unavailable => {} } } ✻ uv_close 是异步的:libuv 保存原始句柄指针直到下一个循环滴答,然后调用 on_pipe_close,释放分配的内存。但 `pipe` 是一个 Box,在这个 match 分支末尾被 drop 了——libuv 持有的是已释放的内存,然后关闭回调又释放了它一次。先是释放后使用,然后是双重释放。 ✻ Box::leak(pipe).close(Subprocess::on_pipe_close) f0a454376c7 · win-review: js_bun_spawn_bindings.rs 在异步 uv_close 之前泄漏 Box 以避免 on_pipe_close 中的 UAF/双重释放 对抗性审查者实际捕获的三个 bug —— 每个引用的提交都在主题行中带有其审查归属。这三个都编译通过;三个看起来都合理。审查者是第二个 Claude,位于它自己的上下文窗口中:它只获得 diff,没有别的——没有实现者的推理——并被指示找出它错误的方式。代码来自被引用的提交;相同的 bug,相同的修复。## 这看起来像什么?(https://bun.com/blog/bun-in-rust#what-does-this-look-like) 如果你即将做一件大而昂贵的事情,节省时间和金钱

相似文章

Bun 的 Rust 重写已合并

Hacker News Top

Bun JavaScript 运行时和工具包已用 Rust 重写,标志着从原本的 Zig 实现发生了重大转变。

Bun 的 Rust 重写已合并

Lobsters Hottest

Bun,JavaScript 运行时和包管理器,已合并其核心从 Zig 到 Rust 的重写,可能提升性能和可维护性。

Bun 已转换为 Rust。接下来怎么办?

Hacker News Top

Anthropic 收购了 Bun,并使用 Claude Code 智能体在九天内将整个运行时从 Zig 重写为 Rust。该重写通过了 99.8% 的测试,但引入了超过 10,000 个 unsafe 块,引发了对内存安全性益处的质疑。

用Rust重写Bun

Simon Willison's Blog

Jarred Sumner详细介绍了使用AI将Bun从Zig重写为Rust的过程,花费了16.5万美元的token,并使启动速度提高了10%。

我对Bun用Rust重写的看法

Lobsters Hottest

Zig的创建者Andrew Kelley分享了他对Bun从Zig重写为Rust的决定的看法,讨论了项目的历史、Oven公司的管理问题以及代码质量方面的担忧。