用 Rust 重写 Bun

发布时间:2026/7/17 14:29:53
用 Rust 重写 Bun 用 Rust 重写 Bun2026-07-08 · Jarred Sumner摘要Bun 用 11 天完成 Zig 到 Rust 全量重写Claude Code 主导测试全绿实现零跳过零删除。Zig 手动内存管理与 JS 引擎 GC 在同一个运行时中混用催生了 13 类释放后使用和内存泄漏——Rust 的 borrow checker 从语言层面根除这类崩溃。文章复盘这场 AI 驱动的重写决策、Anthropic 收购背景和稳定性量化收益。本文译自 Jarred Sumner 的文章 Rewriting Bun in Rust。以下是原文翻译声明Bun 于 2025 年 12 月被 Anthropic 收购。我和 Bun 团队的其他成员现在都在 Anthropic 工作。在这次的 Rust 重写过程中我用了 Claude Fable 5 的预发布版本。Bun 最近完成了一次从 Zig 到 Rust 的大规模重写整个过程只用了 11 天。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 辅助花了一年写出来的。像 Bun 这种野心勃勃的项目最常见的结局就是成为 GitHub 主页上那些死掉的 Side Project 之一。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()导致释放后使用崩溃在node:http2中重入的 JS 回调如超时监听器、options 获取器或写入回调内的session.request()触发哈希表重哈希内部流指针失效引发释放后使用崩溃在UDPSocket.send()和sendMany()中valueOf()或toString()回调内的用户代码在捕获载荷与实际发送之间分离了ArrayBuffer导致释放后使用在Buffer#copy和Buffer#fill中valueOf回调在参数强制转换期间分离或调整了底层ArrayBuffer的大小导致崩溃和越界读取在UDPSocket.sendMany()中用户 JS 回调在迭代中途改变了 socket 的连接状态导致堆越界写入在crypto.scrypt中输出缓冲区分配失败时回调和受保护的密码/盐值缓冲区从未释放导致内存泄漏SSLWrapper.init在错误路径上泄漏了 strdup 复制的密码短语在tlsSocket.setSession()中每次调用在d2i_SSL_SESSION之后缺失了对应的SSL_SESSION_free每次泄漏一个SSL_SESSION约 6.5 KBfs.watch()的监听器在调用.close()后从未被垃圾回收原因是引用计数下溢导致每个监听器被永久固定为 GC 根节点在 CSS 解析器中background-clip含有厂商前缀和多层背景时导致双重释放崩溃DuplexUpgradeContext从未释放——每次tls.connect({ socket: duplex })完整泄漏一个在MessageEvent中BroadcastChannel或MessagePort并发访问时GC 标记线程可能观察到m_data中出现撕裂的变体导致竞态条件崩溃我们本可以一直这样逐个修 bug但我们对信赖我们的用户有责任做得更好——从系统层面预防这类问题的再次发生。我们已经做的事情修补了 Zig 编译器加入 Address Sanitizer 支持。每次提交都用 ASAN 跑测试套件。在 Windows 上发布 Zig 安全检测的 ReleaseSafe 版本用 FuzzilliV8 和 JavaScriptCore 用的 JavaScript 引擎模糊测试工具全天候对 Bun 的运行时 API 进行模糊测试大量端到端的内存泄漏测试这些措施已经比很多项目做得更多了。只要足够聪明不犯错误就行那份 bug 修复清单让人沮丧每晚睡前都在担心 Bun 的崩溃我厌倦了这种感觉。我不怪 Zig——其他 Zig 用户没有我们这么多 bug把垃圾回收与手动管理的内存混用对软件来说足够罕见没有哪种语言会专门为此设计。没有 Zig 我们走不到今天我永远感激它。直到不久前对 Bun 这样的项目来说编程语言的选择还是一条单行道。JavaScript 是门垃圾回收语言JavaScriptCore以及 V8这类现代引擎对异常处理和垃圾回收器有严格的规则。Zig 和 C 一样不替你管理内存——对很多项目来说这种权衡正是选 Zig 的好理由。Zig 没有构造函数/析构函数大多数清理工作需要在每个调用点用defer显式写出。对 Bun 来说正确处理垃圾回收值和手动管理值的生命周期一直是稳定性问题的主要来源——最常见的是小内存泄漏偶尔会引发崩溃。每次内存分配都需要仔细审查这些字节在哪释放怎么确保只释放一次JavaScript 异常检查对了没有这个指向垃圾回收内存的指针对保守栈扫描器可见吗这到底是垃圾回收内存还是手动管理的内存对于稳定性问题越早发现越好。模糊测试在代码合并后进行。CI 在代码推送时触发。运行时安全检查和地址消毒器在代码运行时生效希望是在开发阶段CI 之前就能跑起来。减少这类问题的一个常用方法是确保需要清理的代码始终且只执行一次。Zig 被设计为简单语言没有隐藏的控制流所以它用显式的defer关键字在作用域结束时执行代码而不是像 C 那样靠隐式的析构函数或 Rust 的隐式Drop。语言清理机制Zigdefer,errdeferC~Destructor析构函数, Move移动语义RustDrop对于 Zig 代码到底应该在什么时候执行清理代码如果把同一个*T传给多个不同的函数怎么知道它何时不再被访问、可以清理当某些函数在调用后仍需继续引用这段内存时又该怎么办我们当前的方案是几种策略的组合arena 生命周期即内存可访问的作用域是清晰的解析器状态不会逃逸出调用函数AST 节点在这里是个好选择引用计数高度警惕许多项目选择用风格指南来回答这类问题。TigerBeetle 的 TigerStyleZig 语言是一个例子Google 那篇 3.1 万字的 C 风格指南是另一个。风格指南的难点在于执行。怎么确保风格指南被遵守过去只能靠代码审查配合 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 呢Bun 大约 20% 的代码是 C 写的而且嵌入了好几个 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那份 bug 列表里很大一部分是 use-after-free、double-free 以及在错误路径上“忘了释放”。在安全的 Rust 中这些会变成编译器错误还能靠Drop实现类似 RAII 的自动清理。编译器报错比代码风格规范好得多。从历史经验看重写是个坏主意。不算注释Bun 有 535,496 行 Zig 代码。换一种语言重写一个小团队得干一整年。那一年里bug 修复、安全更新、功能开发全部冻结。要交付一个能用的东西风险最小的方式是机械地将 Zig 移植到 Rust最小化行为变更直接用现有的 Bun 测试套件。好在 Bun 自己的测试套件是用 TypeScript 写的跟运行时的编程语言无关。一整年对用户零影响——这不现实。所以通过代码风格来强制修复稳定性问题是我们当时的最佳方案。这也是我们在 Bun 代码库中加入受 Rust 启发的智能指针时的计划。但说实话我不想那么做。自制的智能指针跟 Rust 比体验更差还没有任何安全保障。反过来想——如果我花一周时间试试能不能用 Anthropic 的新模型把 Bun 重写成 Rust 呢一开始我没指望能成。几天后测试套件通过的比例越来越高新生成的 Rust 代码跟原来的 Zig 代码高度吻合。我的看法从“值得一试”变成了“我要合并它”。Claude把 Bun 重写成 Rust搞砸这件事的方式太多了。比如对 Claude 说一句“把 Bun 重写成 Rust别犯任何错误”然后祈祷它能成功——我没这么干。想想一个真人会怎么做。第一个大问题是增量重写还是一次性全重写根据我早期把 esbuild 的 transpiler 从 Go 移植到 Zig 的经验没有 LLM一次性全重写效果更好。增量重写会产生大量临时代码——你希望它们最终被删掉但在中短期内会很痛苦。第二个大问题具体怎么做怎么让 Rust 版的 Bun 跟原来保持一致——同样的架构、性能和功能集——同时还能享受 Rust 的 borrow checker 这类特性怎么保证团队在重写之后还能继续维护答案是先做一次看起来像“把 Zig 代码编译成 Rust”的机械重写。等 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 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 修改循环来修复问题。一个 PR 增加了 100 多万行代码你怎么审查怎么建立起足够的信心去负责任地合并大量 LLM 编写的代码答案是一个包含百万级断言的、与语言无关的测试套件对抗性代码审查以及——出问题时——修复生成代码的流程本身而不是手动修补代码。对抗式审查对抗式审查要求 Claude在独立的上下文窗口中穷举出所有变更可能引入 bug 或无法正常运行的理由。分离的上下文窗口人类场景下审查代码的人不是写代码的人。写代码的人想合并代码这种倾向会让他们在代码还没就绪时就急于发布。Claude 也一样。写代码的 Claude 希望代码被接受审查的 Claude 则希望找出问题。每位实现者配 1 位和 2 位或更多对抗式审查者。审查者唯一的事就是找 bug 和代码不对的地方。实现者不审查审查者不实现。Bug 1 of 3 · 异步关闭实现者src/runtime/api/bun/js_bun_spawn_bindings.rs · 编译通过forstdioin[spawned_stdout,spawned_stderr]{matchstdio{StdioResult::Buffer(mutpipe){// pipe: Boxuv::Pipe — 交给 libuv 去关闭pipe.close(Subprocess::on_pipe_close)}StdioResult::Fd(fd)fd.close(),StdioResult::Unavailable{}}}对抗式审查者uv_close 是异步的——libuv 会保留原始句柄指针直到下一个事件循环 tick然后调用 on_pipe_close 释放内存。但pipe是一个 Box在这个 match 分支末尾就会被释放——libuv 此时拿到的是一块已释放的内存而关闭回调又会第二次释放它。先是用后释放然后是双重释放。修复Box::leak(pipe).close(Subprocess::on_pipe_close)Bug 2 of 3 · 负 timespec实现者src/runtime/node/node_fs.rs · 编译通过// 将 f64 秒拆分为 timespec 格式的 {sec, nsec}letsect.trunc();TimeLike{sec:secasi64,nsec:((t-sec)*1e9)asi64,}对抗式审查者对于负的、非整数的时间比如 1970 年之前的文件修改时间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 i64Bug 3 of 3 · 贪婪的 unwrap_or实现者src/css/values/color.rs · 编译通过// color-mix() 的每一侧都可以省略百分比// 缺失的一侧默认取另一侧的剩余值letp1first.percentage.unwrap_or(1.0-second.percentage.unwrap());对抗式审查者unwrap_or 会立即求值其参数——即使 first.percentage 是 Somesecond.percentage.unwrap() 仍然会执行。所以 color-mix(in srgb, red 40%, blue) 这种只有第二侧缺省百分比的情况在 unwrap_or 有机会忽略它之前参数表达式内部就已经 panic 了。unwrap_or_else 接受闭包保持惰性求值。修复let p1 first.percentage.unwrap_or_else(|| 1.0 - second.percentage.unwrap());这是对抗式审查者实际抓到的三个 bug——每条相关的提交都在主题行标注了审查归属。三个 bug 都能编译通过看上去也都合理。审查者是另一个拥有独立上下文窗口的 Claude它只拿到 diff没有实现者的任何推理过程任务就是找出哪里有问题。实际效果如何要做一件大而贵的事情先降低风险能省时间和钱。准备工作写代码之前我花了大约 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 个。每个文件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 修改工作流明确指示永远不要运行git stash或git reset也不要用任何一次性提交多个文件的git命令。同样禁止cargo。慢命令一律不许跑。之后 Claude 恢复了工作流。这次跑起来了但还是太慢于是我把它拆成 4 个工作流分片每个有独立的工作树共 4 个每个工作树里 16 个 Claude 并发提交和推送文件。终于开始写代码得益于所有这些并行化和准备Claude 在峰值时每分钟写了约 1,300 行代码。每一行代码都经过两个独立的对抗式审查者也是 Claude审查提交前经历一轮修复。当然这些代码还没一个能跑起来。11 天 × 24 小时 · PDT6,502 次提交 — 每小时 1–695 次提交移植分支上的每次提交合并提交除外按小时分桶。峰值小时695 次提交。注意到提交时间不均匀了吗因为我忘了提高运行它的 EC2 实例的默认 IOPS。一个慢grep命令就能让磁盘读写冻结好几分钟。把编译器错误当工作队列写完所有代码后我让 Claude 写一个新的工作流逐个修复所有编译器错误。我们按 crate 逐个处理。剩余约 16,000 个错误— 5 月 6 日周三凌晨 12:40 PDT阶段 D64 个 Claude 分布在 4 个工作树上每个循环1 人修复 → 2 人审查 → 1 人应用阶段 D 的工作方式cargo check把约 16,000 个错误写入文件按 crate 分组工作流把它们分配给 64 个 Claude——4 个工作树各跑 16 个循环每个循环里一个 Claude 修复、两个审查、一个应用。最棘手的错误类别是循环依赖。我们的 Zig 代码库是一个编译单元相当于一个 crate。我想把新的 Rust 代码库拆成约 100 个 crate编译更快但必须避免循环依赖同时尽量减小与原始 Zig 的差异。我为此提前准备的 PR 还不够完善。我没有从头开始而是又跑了一个工作流来分析存在循环依赖的代码应该放哪记录所有结论——接着再跑一个工作流来完成重构。修复循环依赖后暴露了约 16,000 个编译器错误。对一个人来说这个数字很大但对 64 个同时工作的 Claude 来说也不是不能处理。为了最大化并行度工作流逐个 crate 循环执行。对每个 crate运行cargo check按文件分组输出把错误保存到文件修复该 crate 内的所有编译器错误2 名对抗式审查者审查改动1 名修复者应用修复为了防止多个 Claude 互相踩踏cargo check只在最开始时运行和其他运行一样全程不碰git直到结束。又一段弯路Claude 把“让所有 crate 都能编译”理解成了“把有编译错误的函数先 stubbing 掉”。Claude 还开始写长得可疑的解释性注释为各种权宜方案辩解于是我加了一条规则让对抗式审查者直接驳回如果你需要一大段注释来解释为什么这个权宜之计没问题那代码本身就是错的——去修代码。改了一版提示词几小时后这些问题不再出现。Smoke tests模型们总爱说“烟雾测试”。cargo check通过后下一步是让它能编译并运行bun --version。过程中遇到了链接器错误。然后程序一启动就崩溃。下一个目标是让它能跑bun test file。搞定这个我们就能开始跑测试了。上另一个工作流循环处理 Bun CLI 子命令把每个失败的堆栈跟踪连同对应的子命令保存到文件按子命令分组每组由 1 个 Claude 负责修复2 个对抗式审查者1 个修复者应用建议让测试套件在本地通过这个工作流循环处理测试文件。从代码库中按文件夹分片到 4 个工作树之一跑大约 100 个随机测试文件。每个失败的测试把堆栈跟踪和错误保存到文件1 个实现者提出修复方案2 个对抗式审查者审查然后 1 个修复者应用修改。更多的弯路测试套件有很多内存泄漏测试和少数需要一分多钟的集成测试——比如一个跑next dev并检查热模块重载能 100 次抓到变更的测试。其中几个在 debug 构建中会超时。还有耗尽机器最大 TCP 套接字数量的压力测试有读写数 GB 数据的测试以及衍生约一万个进程的测试。这些需要的隔离强度远超说声“请”就能搞定所以我们用了systemd-runcgroups来限制内存和 CPU 使用隔离 pid 命名空间。即便如此机器还是因为磁盘空间耗尽崩溃了好几次。让测试套件在 CI 中通过首次 CI 运行的两天后失败列表从 972 个测试文件降到了 23 个。又过了一天半Linux 全线变绿——那一刻我第一次觉得这次 Rust 重写是真的能成。6 / 6 个平台全部变绿— 构建 #54202 · PDT 5 月 14 日周四凌晨 12:23按平台展示的每次 CI 构建的分片测试结果跨越 135 个执行过测试的构建从 BuildKite 挖出 420 个数据点。每条跑道标记了其完整测试套件首次通过的时间——Linux 的 60 个分片比 Windows 早将近一整天全部变绿。0 个测试被跳过或删除11 天5 月 3 日 → 5 月 14 日合并· 6,778 次提交平台expect() 调用次数测试用例数文件数Debian 13 x641,386,82660,6244,174macOS 14 arm641,259,95358,8504,175Windows 2019 x641,007,54457,3374,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 天否则我得自己写 harness 才能实现。工作还在继续合并 Rust 移植以来我们完成了来自 Claude Code Security 的 11 轮安全审查处理了所有发现的问题。我们还为 Bun 里的每个解析器加了全天候的覆盖率引导模糊测试——包括 JavaScript、TypeScript、JSX、CSS、JSON5、JSONC、TOML、YAML、Markdown、INI、Bun Shell 脚本、semver 范围、.patch 文件和 CSS 颜色值。模糊测试器自动把发现的问题发给 Claude由其提交包含复现步骤和修复方案的 PR然后人工审核。到目前它对解析器执行了 1000 亿次测试产生了约 15 个 PR。现在 Bun 约 4% 的 Rust 代码在unsafe块里在约 78 万行代码中约 1.3 万个unsafe关键字散布在约 2.7 万行内其中 78% 的块只有一行——要么是一个来自 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负责把文件添加到前端开发服务器的热重载图中。发布构建里这段代码不再执行导致特定场景下 HMR 失效——那些使用 React 且包含 HTML 路由的项目热重载文件被失效时报错Cannot destructure property isLikelyComponentType of k。debug 构建一切正常。#30678奇数长度的切片Bun 的 Zig 辅助函数reinterpretSlice(u16, bytes)在支持切片的内置类型转换之前就已存在用divTrunc转换忽略末尾的奇数个字节。bytemuck::cast_slice遇到这种情况会直接 panic。Blob.text()处理以 UTF-16 BOM 开头、后面跟奇数个字节的数据时不再返回字符串而是导致进程 panic。我们回到了忽略奇数个字节的做法buf[..buf.len() !1]。#31188边界检查在 macOS 和 Linux 上我们用ReleaseFast编译 Bun 的 Zig 代码去除边界检查。Rust 的发布构建保留边界检查。Bun 的模块解析器把长文件名内部化到一个全局列表里列表不够时溢出到溢出块。原始 Zig 代码把每个溢出块的大小设为count / 4也就是 2048。移植代码里留了一个占位符/// ……所以先用一个非零的临时值等 Phase B 把/// 每次实例化的值传过来再改。pubconstBSS_OVERFLOW_BLOCK_SIZE:usize64;内部化文件名的上限从 840 万降到了 270,272——真实项目确实会达到这个上限。同时从 Zig 移植过来的一个ptrs[4095]越界访问也变得可达。Rust 越界时直接 panic不像 Zig 那样写入越界地址。不过 Zig 如果用ReleaseSafe模式我们只在 Windows 上用也会 panic。#31503comptime格式化字符串Output.pretty把r和d颜色标记重写为 ANSI 转义序列。Zig 里fmt是编译期参数颜色标记在参数被替换之前就处理完了。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)。#30693Rust 让 Bun 更好Bun v1.4.0 修复了 v1.3.14 中可复现的 128 个 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()调用泄漏了几 MB 内存——解析后的源代码文本和 AST 符号表超出了它们所属的构建生命周期。// 在同一个进程中把同一个 60 模块项目打包 2,000 次for(leti0;i2_000;i){awaitBun.build({entrypoints:[“./index.js”],minify:true,sourcemap:“external”,});}Bun v1.3.14 中每次构建永久泄漏约 3 MB——像每次请求都做打包的开发服务器这样的工具最终会耗尽内存。Bun v1.4.0 中内存稳定在了一个水平构建次数Bun v1.3.14Bun v1.4.05001,914 MB526 MB1,0003,506 MB586 MB1,5005,097 MB608 MB2,0006,745 MB609 MB之前用 Zig 尝试过一次因为没有Drop这样的等价物难以有信心合并所以没有合并。二进制体积缩小Rust 重写最初的改动让二进制体积在 Windows 上减少 3.8 MBmacOS 上 5.5 MBLinux 上 6.8 MB。主要原因是 Zig 代码中用了太多comptime。初始缩小之后团队用链接器优化如 Identical Code Folding、移除 ICU 中未使用的数据、以及用 zstd 字典按需延迟解压 libicu 中的小部分内容进一步缩小了体积。结合 Rust 重写、ICU 改动和 Identical Code FoldingLinux 和 Windows 上 Bun 的二进制体积缩小了约 20%。版本平台体积Bun v1.4.0canaryWindows76 MBBun v1.3.14Windows94 MBBun v1.4.0canaryLinux70 MBBun v1.3.14Linux88 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.lifetime.start和llvm.lifetime.end内部函数让 LLVM 可以重用栈空间槽位。这样包含嵌套作用域的大型函数能显著减少栈空间使用。之前我们通过把特大函数拆成多个小函数手动绕开了一个已知问题。快 2% 到 5%Rust 支持 C/C 和 Rust 之间的跨语言链接时优化可以实现跨语言内联。我们在 Linux x64EC2Xeon Platinum 8488C上对 Bun v1.3.14 和 v1.4.0 做了基准测试。HTTP 吞吐量用 oha 对 hello-world 服务器测量应用工作负载用 hyperfine 测量。HTTP 吞吐量req/s3 轮平均服务器Bun v1.3.14Bun v1.4.0ΔBun.serve169.6k177.7k4.8%node:http103.8k108.5k4.5%Elysia158.9k163.3k2.8%express64.5k66.6k3.2%fastify91.5k95.9k4.8%应用 / CLIhyperfine工作负载Bun v1.3.14Bun v1.4.0Δnext build13.62 s13.03 s4.5%vite build (tsc vite)1.69 s1.65 s2.2%tsc -b --force0.94 s0.89 s4.7%生产环境Prisma 在 Bun 的 Rust 重写版上启动了 Prisma Compute 公测。“我们遇到了内存泄漏和连接池在 VM 暂停恢复后无法恢复的问题。Rust 重写版出来后我们用同样的故障模式测试了它——处理得很完美。”—— Alexey OrlenkoClaude Code v2.1.1816 月 17 日发布及更高版本用了 Bun 的 Rust 移植版。Linux 上启动速度快了 10%但除此之外几乎没人注意到。无聊也是好事。发布Bun v1.3.14 是用 Zig 写的最后一个版本。Bun v1.4.0 将是用 Rust 写的第一个版本。目前在 canary 频道可用——如有问题请报告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; } // ... } // ... }pubfncan_merge_symbol_kindsconstIS_TYPESCRIPT_ENABLED:bool(scope_kind:Kind,existing:symbol::Kind,new:symbol::Kind,)-SymbolMergeResult{ifexistingsymbol::Kind::Unbound{returnSymbolMergeResult::ReplaceWithNew;}ifIS_TYPESCRIPT_ENABLED{// 在 TypeScript 中导入可以静默地与模块内的符号冲突。// 推测这是因为这些导入可能只是类型层面的//// import {Foo} from bar// class Foo {}//ifexistingsymbol::Kind::Import{returnSymbolMergeResult::ReplaceWithNew;}// ...}// ...}能看懂原始 Zig 代码的人也能看懂机械翻译后的 Rust 代码。我审查了最初的 Rust 重写 PR检查对抗式代码审查智能体是否正确捕捉了 Zig 和 Rust 之间的差异确保它们遵守了移植指南和生命周期指南同时自己也逐行对比阅读了大量代码。下一步Bun v1.4 让 Bun 更快、更小、内存占用更低还给团队提供了系统性持续提升稳定性的工具Rust 的 borrow checker、Miri已在 CI 中对越来越多代码运行、LeakSanitizer以及针对解析器的 24/7 覆盖率引导模糊测试。还有更多待重构的内容但一切已经有了好的开端。这次 Rust 重写由一个熟悉代码库的工程师团队来做需要一年。而一位工程师配合 Fable 并密切监控 Claude Code11 天内从零做到了全平台 100% 测试套件通过。今天一位工程师能做的事情比一年前多得多。 更多内容请访问 原文链接往期精选PHP周刊2026W28 | 安全更新、Laravel 13.18、Twig 3.28Python周刊2026W28 | PyPI命名空间、JIT路线图、Python 3.15预览Web前端周刊2026W28 | TypeScript 7.0 RC、npm v12、Node.js 26.5.0、ECMAScript 2026、React Hook Form 7.81.0