BunをRustで書き直す
(bun.com)- Zigから始まったBunは、月間2,200万回以上ダウンロードされるランタイムに成長したが、GCベースのJavaScriptエンジンと手動メモリ管理が組み合わさることで安定性問題が繰り返され、Rust移行のきっかけとなった
- 535,496行のZigコードを人手で1年かけて移す代わりに、Claude Codeの動的ワークフロー約50個と最大64個のClaudeインスタンスを11日間並列実行した
- 移植は
PORTING.md、LIFETIMES.tsv、実装者1人と敵対的レビュアー2人以上、既存のTypeScriptテストスイートで検証され、6プラットフォームのCIで100%合格した - Rust移行後のBun v1.4.0は、v1.3.14で再現する128件のバグを修正し、計測可能なメモリリークをすべて修正、Linux・Windowsのバイナリサイズを約20%削減した
- Bun v1.3.14は最後のZig版で、v1.4.0は最初のRust版としてcanaryで提供される。チームはborrow checker、Miri、LeakSanitizer、24/7のカバレッジベースのファジングを安定性改善ツールとして使っている
Zigから始まったBunと安定性問題
- Bunは、esbuildのJavaScript・TypeScriptトランスパイラをGoからZigへ行単位で移植したプロジェクトとして始まった
- 最初のZigコードは2021年4月16日に書かれ、Zigの低レベル制御と性能志向の設計がBunの初期実装を可能にした
- 初期のBunは1人が1年かけてZigで書いたもので、範囲は非常に広かった
- JavaScript、TypeScript、CSSのトランスパイラ・ミニファイア・バンドラ
- npm互換パッケージマネージャ
- Jest風のテストランナー
- Node.js・TypeScript互換のモジュール解決
- HTTP/1.1・WebSocketクライアント
fs、net、tlsなどのNode.js API実装
- 現在のBun CLIは月間2,200万回以上ダウンロードされ、Claude CodeとOpenCodeがランタイムとして使用し、Vercel、Railway、DigitalOceanなどがファーストパーティサポートを提供している
繰り返されたメモリ安全性バグ
- Bun v1.3.14で修正されたバグの例には、use-after-free、double-free、メモリリーク、out-of-boundsアクセス、race conditionが含まれる
node:zlibのasync.write()中に.reset()を呼ぶことで発生するheap-use-after-freenode:http2の再入可能なJSコールバックがhashmapのrehashを引き起こし、内部ストリームポインタが無効化されるuse-after-freeUDPSocket.send()とsendMany()で、valueOf()またはtoString()コールバックがArrayBufferをdetachする問題Buffer#copy、Buffer#fillで、引数のcoercion中にArrayBufferがdetachまたはresizeされることによるcrashとout-of-bounds readcrypto.scrypt、tlsSocket.setSession()、fs.watch()関連のメモリリーク- CSS parserのvendor prefixとmulti-layer background処理中のdouble-free
BroadcastChannelまたはMessagePortへの同時アクセス中のMessageEventrace condition crash
- 以前から安定性強化のために複数の仕組みを使っていた
- Zig compilerにAddress Sanitizerサポートをパッチし、すべてのコミットでASANテストスイートを実行
- WindowsにはZigのsafety-checked ReleaseSafeビルドを配布
- FuzzilliでBunランタイムAPIを24/7ファジング
- end-to-endのメモリリークテストを多数運用
- Zig自体が問題だという立場ではなく、GC値と手動管理メモリを同時に扱う要件が安定性問題の主な原因だった
Rustを選んだ理由
- JavaScriptはGC言語であり、JavaScriptCoreやV8のようなエンジンは例外処理とGCに厳格なルールを持つ
- ZigはCと同様にメモリを自動管理せず、コンストラクタ・デストラクタがなく、cleanupは多くの場合、各call siteで
deferにより明示する必要がある - Bunでは、GC値と手動管理値のlifetimeを正しく扱うことが安定性問題の大きな原因だった
- 確保されたバイト列がどこで解放されるかを確認する必要がある
- 一度だけ解放されることを保証する必要がある
- JavaScriptの例外処理を正しく確認する必要がある
- GCポインタがconservative stack scannerから見えることを確認する必要がある
- Zigのcleanup方式は明示的な
defer、errdeferであり、C++はdestructorとmove、RustはDropを使う - Bunの既存Zigコードでは、arena lifetime、reference counting、入念なレビューが混在していた
- スタイルガイドとコードレビューでownershipルールを強制することもできるが、Rustの安全なコードではuse-after-free、double-free、error pathでのfree漏れがコンパイラエラーになる
- Bunコードの約20%はC++で、複数のC/C++ライブラリを内蔵している
- JavaScriptCore
- uWebSocketsとusockets
- lshpackとlsquic
- BoringSSL
- SQLite
- C++も選択肢になり得たが、依然としてスタイルガイドとコードレビューに依存し、ASANがあってもメモリ破壊やリークは発生し得る
書き直し戦略:一度に、機械的に
- 既存のBun Zigコードはコメントを除いて535,496行あり、従来型の書き直しなら小規模なエンジニアチームで1年ほどかかる作業と見積もられた
- バグ修正、セキュリティ修正、機能開発を1年間止めることはできなかったため、ユーザーから見た挙動の変更を最小化する機械的移植が最も低リスクなアプローチとして選ばれた
- BunのテストスイートはTypeScriptで書かれており、ランタイム実装言語に依存していなかった
- インクリメンタルな書き直しは一時的なコードを作り、後で削除されることを期待しなければならないため、短期・中期的に苦痛が大きいと判断し、全体を一度に移した
- RustコードはZigコードをtranspileしたように見えるよう書き、Bun v1.4以降に段階的に
unsafeを減らし、idiomatic Rustへリファクタリングしていく方針を選んだ
Claude Codeの動的ワークフロー
- Rustへの書き直しでは、Claude Code上で約50個の動的ワークフローが11日間継続して実行された
- ワークフローは、移植ガイドの作成からファイル変換、コンパイルエラー修正、subcommandの復旧、全テスト通過、大規模cleanupまで続いた
- Zigのパターンと型をRustのパターンと型にマッピングする移植ガイドを生成
- すべての
.zigファイルをPORTING.mdとLIFETIMES.tsvに従って.rsファイルへ機械的に移植 - crateごとのコンパイラエラー修正
bun test、bun buildのようなsubcommandの動作復旧- 全テストスイートの通過
- 大規模なリファクタリングとcleanup
- 期間中の大半は、人間がワークフローの出力を読み、問題やバグを確認し、Claudeがループを修正できるようプロンプトを調整した
- 事前作業として、Claudeと約3時間かけてZigコードベースのパターンをRustへマッピングする方法を議論し、その結果が
PORTING.mdとしてシリアライズされた - 手動メモリ管理コードにRust lifetimeを追加するため、すべてのstruct fieldのlifetimeを分析するワークフローを実行した
- 複雑なlifetimeを持つfieldを見つける
- lifetimeを提案する
- 敵対的レビューagent 2つが検討する
- フィードバックを反映して
LIFETIMES.tsvとして保存する
敵対的レビュー方式
- 各実装担当の Claude とは別の context window に敵対的レビュアー Claudeを置き、レビュアーには diff だけを受け取った状態で、コードは間違っていると仮定してバグを探すよう指示した
- 基本構成は実装者 1 人、敵対的レビュアー 2 人以上、fixer 1 人で構成される
- レビュアーが実際に見つけたバグはいずれもコンパイルは通ったが、動作上の問題があった
uv_closeは非同期なのにBox<uv::Pipe>が match arm の終わりで drop され、libuv が freed memory を保持することになる use-after-free と double-free- 負の非整数 file time で
trunc()を使うと負のnsecが生じる timespec のエラー unwrap_orが引数を eager 評価するため、color-mix()の percentage 省略ケースで panic するエラー
- 人間によるレビューと同様に、作者とレビュアーの context を分離し、実装者が merge を望むことで生じ得るバイアスを減らした
大規模ポーティングの実行と並列化
- 全 1,448 個の
.zigファイルを移す前に、まず 3 つのファイルで手順を検証した- 実装者 1 人が
.rsファイルを作成する - レビュアー 2 人が
.zigと動作が一致し、PORTING.md、LIFETIMES.tsvに従っているかを確認する - fixer 1 人が提案を適用する
- 実装者 1 人が
- 全ファイルのポーティング初期には、複数の Claude が
git stash、git stash pop、git reset HEAD --hardを実行して互いに衝突した - その後、ワークフローに
git stash、git reset、特定ファイルの commit ではないgitコマンド、cargoのような遅いコマンドを禁止するルールを追加した - 最終的に 4 つの workflow shard と 4 つの worktree を使い、各 shard で 16 個の Claude がファイルを commit・push した
- 並列化と事前準備のおかげで、ピーク時に Claude は毎分約 1,300 行のコードを書いた
- port ブランチの merge を除く commit は 6,502 個、ピーク時間は 695 commits で、最終的に landed した diff は +1,009,272 行だった
- EC2 インスタンスのデフォルト IOPS を増やしていなかったため、遅い
grep1 つでディスクの読み書きが数分間止まる問題もあった
コンパイルエラーと crate 分割
- すべてのコードを書いた後、Claude ワークフローがコンパイラエラーを修正した
- Zig コードベースは事実上 1 つの compilation unit であり、Rust コードはより高速なコンパイルのために約 100 個の crate に分けようとした
- 最も厄介なエラー種別は循環依存だった
- Rust での書き直し直前の crate 分割 PR だけでは十分ではなかった
- 別のワークフローが、循環依存のあるコードをどこに置くべきかを分類して記録した
- さらに別のワークフローがそのリファクタリングを実施した
- 循環依存を解消した後、約 16,000 件のコンパイラエラーが明らかになった
- これらのエラーは crate ごとに並列処理された
- 各 crate で
cargo checkを実行する - 出力をファイルごとにまとめて保存する
- その crate のコンパイルエラーを修正する
- 敵対的レビュアー 2 人が変更をレビューする
- fixer 1 人が修正を適用する
- 各 crate で
- Claude が「すべての crate をコンパイルできるようにしよう」を関数 stub の生成と解釈する false start もあった
- 長い説明コメントで workaround を正当化しようとするパターンが生じたため、「段落ほどの長さのコメントが必要ならコードが間違っており、コードを直すべきだ」というレビュー規則が追加された
テスト通過までの過程
cargo checkが通った後は、リンクエラー、起動直後の panic、bun --version、bun test <file>の実行を順番に解決した- CLI subcommand ごとの失敗 stacktrace をファイルに保存し、実装者・レビュアー・fixer のループで直すワークフローを使った
- テストファイルのワークフローは、約 100 個のランダムなテストファイルを 4 つの worktree に shard し、失敗ごとの stacktrace とエラーを保存して修正した
- テストスイートには debug build で timeout し得るメモリリークテストや統合テストがあった
next devを実行し、hot module reloading が 100 回の変更を検知するテスト- TCP socket の最大数を使い切る stress test
- ギガバイト単位のディスク読み書きテスト
- 約 1 万個のプロセスを spawn するテスト
- 隔離のため
systemd-runと cgroups でメモリ・CPU 使用量を制限し、pid namespace を分離した - それでもマシンはディスク容量不足で何度も crash した
- 最初の CI 実行から 2 日後、失敗するテストファイルは 972 個から 23 個に減り、そこから 1 日半後に Linux が完全に green になった
- 最終的に 6 つのプラットフォームの CI 全テストが通過した
- macOS x64
- macOS arm64
- Linux x64
- Linux arm64
- Windows x64
- Windows arm64
- 100% テスト通過後、人間がテストが実際に実行され、skip されていないことを手動で確認して merge した
mainに merge された時点は versioned release ではなく、release できるほどの確信にはまだ至っていなかったが、rewrite に専念できるだけの確信を得た状態だった
テスト規模とコスト
- 5 月 3 日から 5 月 14 日の merge までの 11 日間で 6,778 commits が生成された
- テストは削除も skip もされていない
- プラットフォーム別のテスト規模は次のとおり
- Debian 13 x64:
expect()1,386,826 回、テスト 60,624 個、ファイル 4,174 個 - macOS 14 arm64:
expect()1,259,953 回、テスト 58,850 個、ファイル 4,175 個 - Windows 2019 x64:
expect()1,007,544 回、テスト 57,337 個、ファイル 4,173 個
- Debian 13 x64:
- pre-merge 作業には uncached input token 59 億、output token 6 億 9,000 万、cached input token read 720 億が使われた
- API 価格ベースの費用は約 16 万 5,000 ドルである
- 人間が直接行っていたなら、コードベース全体の context を持つエンジニア 3 人で約 1 年かかったと評価された
- 使用モデルは pre-release の Claude Fable 5 で、Bun は 2025 年 12 月に Anthropic に買収されたという disclosure が含まれる
セキュリティレビュー、ファジング、unsafe の状況
- Rust port の merge 後、Claude Code Security で11 ラウンドのセキュリティレビューを完了し、findings に対応した
- Bun のすべての parser に対して 24/7 の coverage-based fuzzing が追加された
- JavaScript
- TypeScript
- JSX
- CSS
- JSON5
- JSONC
- TOML
- YAML
- Markdown
- INI
- Bun Shell scripts
- semver ranges
.patchfiles- CSS colors
- fuzzer は見つけたバグを Claude に送り、再現と修正を含む PR を提出させ、人間が PR をレビューする
- これまでの parser 実行は 1,000 億回で、約 15 件の PR につながった
- 執筆時点で Rust コードの約 4% が
unsafeblock 内にある- 約 13,000 個の
unsafekeyword - 約 27,000 行 / 全体約 780,000 行
unsafeblock の 78% は 1 行だけで、C++ 由来のポインタまたは C ライブラリ呼び出しである
- 約 13,000 個の
- JavaScriptCore のような C/C++ ライブラリを使い続けるため、純粋な Rust プロジェクトより
unsafeは常に多くなると説明している
Rust移行後に見つかった regression
- Rust rewrite は大規模な変更だったため、19件の既知の regressionを生み、すべて修正された
- ほとんどは、2つの言語で構文は似ているが意味が異なるコードから生じた
-
debug_assert!内の side effect- Zig の
assertは関数なので、引数はすべての build で実行される - Rust の
debug_assert!は macro なので、release build では式全体が削除される insert_stale呼び出しが release build で消え、React を使う HTML route プロジェクトの特定の HMR ケースが壊れた- 関連 issue: #30678
- Zig の
-
奇数長の slice
- Bun の Zig helper
reinterpretSlice(u16, bytes)は@divTruncを使い、末尾の odd byte を無視していた - Rust の
bytemuck::cast_sliceは奇数長で panic する - UTF-16 BOM の後に奇数バイトが続く
Blob.text()が文字列を返さず、process を panic させる regression があった - 修正は
&buf[..buf.len() & !1]で odd byte を再び無視する方式 - 関連 issue: #31188
- Bun の Zig helper
-
Bounds checks
- macOS と Linux の Zig コードは
ReleaseFastでコンパイルされ bounds check が削除されており、Rust release build は bounds check を維持する - Bun module resolver の overflow block サイズが placeholder の
64のまま残り、上限が840万個の interned filenames から270,272個に下がった - 移植された
ptrs[4095]の off-by-one が実際のプロジェクトで到達可能になり、Rust は out-of-bounds write の代わりに panic する - 関連 issue: #31503
- macOS と Linux の Zig コードは
-
comptimeformat strings- Zig の
Output.prettyはfmtがcomptimeなので、<r>、<d>color marker が引数置換の前に ANSI escape に変換される - Rust 関数には comptime parameter がないため、完成した string で marker を処理し、引数まで誤って rewrite した
bun update -iで OSC 8 hyperlink termination と末尾の<r>marker が衝突し、rがテキストとして出力された- Rust では macro
bun_core::pretty!("<r>{}<r>", hyperlink)が必要だった - 関連 issue: #30693
- Zig の
修正されたバグとメモリリーク
- Bun v1.4.0 は v1.3.14 で再現される128件のバグを修正した
- 範囲はメモリリーク、crash、誤って色付けされた help text まで含む
- Rust の
Dropは値が scope を抜けるときに自動でdrop関数を呼び出す - Zig では各 call site に
deferを追加する必要があり、cleanup の漏れや重複 cleanup が起きやすかった - Rust の
Dropは hidden control flow を受け入れる代わりに、ありがちな footgun を減らす選択 Dropは error handling code の file path 関連のメモリリークを複数修正した- Bun の LeakSanitizer 統合が改善され、すべての native code memory allocations を追跡する
- instrumentable memory leak はすべて修正された
-
Bun.build()リーク改善- 従来の Bun v1.3.14 では、in-process の
Bun.build()呼び出しごとに parsed source text と AST symbol table が build の寿命より長く残り、数 MB ずつリークしていた - 同じ 60-module プロジェクトを1つの process で2,000回 bundle するテストで、v1.3.14 は build ごとに約3MBを継続的にリークした
- Bun v1.4.0 ではメモリ使用量が横ばいになった
- | Builds | 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 |
- 従来の Bun v1.3.14 では、in-process の
バイナリサイズ、スタック使用量、性能
- Rust rewrite の初期変更だけでバイナリサイズが小さくなった
- Windows: 3.8 MB 減少
- macOS: 5.5 MB 減少
- Linux: 6.8 MB 減少
- 主な原因は Zig コードで
comptimeを過度に使用していた点だった - その後、同一コード畳み込み(Identical Code Folding)、ICU の unused data 削除、libicu の一部を zstd dictionary で遅延展開する方式も適用された
- Rust rewrite、ICU 変更、identical code folding を合わせると、Linux と Windows で Bun バイナリサイズが約20%減少する
| Version | Platform | Size |
|---|---|---|
| Bun v1.4.0 canary | Windows | 76 MB |
| Bun v1.3.14 | Windows | 94 MB |
| Bun v1.4.0 canary | Linux | 70 MB |
| Bun v1.3.14 | Linux | 88 MB |
- TOML parser と Bun の recursive-descent parser は stack space の使用量が少なくなった
- Rust の LLVM IR codegen が stack variable に
llvm.lifetime.startとllvm.lifetime.endintrinsic を出力し、LLVM が stack slot を再利用できる - 以前は Zig の open issue を回避するため、とくに大きな関数を複数の小さな関数へ手動でリファクタリングしていた
- Rust は C/C++ と Rust の間の cross-language link-time optimization をサポートし、言語間 inlining が可能
-
Linux x64 ベンチマーク
- Bun v1.3.14 と Bun v1.4.0 を Linux x64 EC2 Xeon Platinum 8488C で比較した
- HTTP throughput は oha、app workload は hyperfine で測定した
- | server | Bun v1.3.14 | Bun v1.4.0 | Δ |
- | --- | ---: | ---: | ---: |
- | Bun.serve | 169.6k req/s | 177.7k req/s | +4.8% |
- | node:http | 103.8k req/s | 108.5k req/s | +4.5% |
- | Elysia | 158.9k req/s | 163.3k req/s | +2.8% |
- | express | 64.5k req/s | 66.6k req/s | +3.2% |
- | fastify | 91.5k req/s | 95.9k req/s | +4.8% |
- | workload | Bun v1.3.14 | Bun v1.4.0 | Δ |
- | --- | ---: | ---: | ---: |
- | next build | 13.62 s | 13.03 s | +4.5% |
- | vite build | 1.69 s | 1.65 s | +2.2% |
- |
tsc -b --force| 0.94 s | 0.89 s | +4.7% |
実際の利用事例とリリース状況
- Prisma は Bun の Rust rewrite の上で Prisma Compute のパブリックベータをリリースした
- Prisma 側は、VM の pause/resume 後に復旧しない connection pool と memory leak の failure mode を Rust rewrite でテストし、その failure mode をうまく処理できたと述べている
- Claude Code v2.1.181、および 6月17日のリリース以降のバージョンは Rust 版 Bun を使用している
- Claude Code の Linux startup は 10% 高速化し、それ以外はほとんどのユーザーがほぼ気づかなかったという
- Bun v1.3.14 は Zig で書かれた最後の Bun バージョンである
- Bun v1.4.0 は Rust で書かれた最初の Bun バージョンで、canary として提供される
チームが得たツールと残された作業
- 新しい Rust コードベースは、既存の Zig コードベースと非常によく似た形を保っている
- 元の Zig コードを理解できる人なら、機械的に翻訳された Rust コードも理解できるように書かれている
- Rust rewrite の PR レビューは、敵対的レビュー agent が Zig と Rust の不一致、ポーティングガイド、lifetime guide の遵守状況を適切に検出できるかを確認し、人間が多くのコードを side-by-side で読む形で進められた
- Bun v1.4 は Bun をより高速かつ小さくし、メモリ使用量を減らし、安定性改善のためのツールを提供する
- Rust borrow checker
- Miri
- LeakSanitizer
- parser を対象にした 24/7 coverage-guided fuzzing
- まだリファクタリングすべき部分は残っており、bun-unsafe-audit が連携されている
- 1人のエンジニアが Fable と Claude Code を綿密にモニタリングし、全テストスイートが全プラットフォームで通過する状態まで 11日 で到達した
1件のコメント
Lobste.rs のコメント
extern "C"のラッパーコードもかなり削れただろうが、それでもコードレビューで強制されるスタイルガイドに頼る必要があり、ASAN があってもメモリ破壊とリークは起き続けただろう。面白いことに Node.js は C++ でもうまく動いているが、Bun を真面目なプロジェクトとして見たことはない。今では Anthropic のマーケティング部門のテストベンチのように見えるので、今後も距離を置くつもりだ。
ネタバレすると、実際にはそこまで注意深くもないし、ミスもする。
unsafeブロック内にあり、そのうち 78% は 1 行だけ」という言い方は安心させようとしているように見えるが、unsafeブロックが 1 行かどうかは重要ではない。その中で安全性の保証を破れば、ブロック外のすべてのコードも潜在的に健全性が破られ得る。Bun の Rust 移植の初期マージには、この種の明白な unsoundness が含まれていた: https://github.com/oven-sh/bun/issues/30719
この issue は、メンテナが CI で Rust の Miri ツールを有効にすることで対応されており、記事の「What's Next」にも Miri (which runs for a growing chunk of code in CI) が含まれているので、その方向で作業が進んでいるのは良さそうだ。
公平に見れば、安全性違反のある Rust であっても、置き換えた Zig コードの品質によっては、より保守しやすくなる可能性はある。それでもunsafe ブロックあたりのコード行数は品質指標ではない。特に、それらのブロックに他のコーディング慣行・専門性・自動検査もなかったなら、なおさらだ。
unsafeブロックが移植の過程で生じたものではなく、プロジェクト要件から来ているという意味に近いように見える。C ライブラリを呼び出すならunsafeブロックは必要で、リファクタリングだけでなくす方法はない。もちろん、その C ライブラリまで書き直すなら可能だろうが、それは後で検討することもできるだろう。
「Bun is joining Anthropic」の発表で Jarred は、Bun の作業のためにエンジニアをさらに採用すると述べていたが、GitHub だけを見ると Bun チームはむしろ縮小したように見える。ここから得られる結論はよく分からず、ただ「Bun は小さなチームだ」という程度だ。
手法自体は興味深いが、記事はマーケティング記事のように読める。どれくらいコストがかかったのかについての分析も不足していたし、Rust への書き直しに伴うリスクもなく、そもそもなぜ書き直しが起きたのかについての具体的な説明も弱い。推測するなら、Zig の no ai policy のためか、Anthropic 内部で Rust に集中しようという方針があったのかもしれない。
そして、なぜ書き直したのかについては、記事はかなり一貫したストーリーを語っていると思う。Bun は問題を捕まえるための対策を講じたにもかかわらずクラッシュが続き、開発者たちはこうした問題をより体系的に防ぐ方法を求めていた。
当初の計画は、特定のコーディングスタイルをより厳格に強制し、スマートポインタを導入する方向だったが、Jared は独自スマートポインタは Rust より使い勝手が悪く、保証もないと見ていた。そこで「Anthropic の新モデルが Bun を Rust に書き直せるか、1 週間試してみたらどうか?」となり、テストスイートの通過率が上がるにつれて、「試す価値はある」から「マージする」に変わっていった流れに見える。
つまり、最初から Rust への書き直しを決めていたというより、「Rust は問題の解決策を与えてくれそうだが、書き直しコストのせいでできない。ところが LLM による移植を試してみると可能性がある。なら LLM による書き直しで行こう」に近いように見える。
これを成し遂げた Jarred、Bun チーム、Anthropic に祝意を送る。