Bun을 Rust로 다시 작성하기

1 hour ago 3
  • 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개 버그를 고치고, instrumentable 메모리 누수를 모두 수정했으며, 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년 동안 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 등이 1st-party 지원을 제공함

반복된 메모리 안정성 버그

  • Bun v1.3.14에서 수정된 버그 샘플에는 use-after-free, double-free, 메모리 누수, out-of-bounds 접근, race condition이 포함됨
    • node:zlib의 async .write() 중 .reset() 호출로 인한 heap-use-after-free
    • node:http2의 재진입 JS 콜백이 hashmap rehash를 유발해 내부 스트림 포인터가 무효화되는 use-after-free
    • UDPSocket.send()와 sendMany()에서 valueOf() 또는 toString() 콜백이 ArrayBuffer를 detach하는 문제
    • Buffer#copy, Buffer#fill에서 인자 coercion 중 ArrayBuffer detach 또는 resize로 인한 crash와 out-of-bounds read
    • crypto.scrypt, tlsSocket.setSession(), fs.watch() 관련 메모리 누수
    • CSS parser의 vendor prefix와 multi-layer background 처리 중 double-free
    • BroadcastChannel 또는 MessagePort의 동시 접근 중 MessageEvent race 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명이 제안을 적용함
  • 전체 파일 포팅 초기에 여러 Claude가 git stash, git stash pop, git reset HEAD --hard를 실행하며 서로 충돌함
  • 이후 워크플로우에 git stash, git reset, 특정 파일 커밋이 아닌 git 명령, cargo 같은 느린 명령을 금지하는 규칙을 추가함
  • 최종적으로 4개 workflow shard와 4개 worktree를 사용하고, 각 shard에서 16개의 Claude가 파일을 커밋·푸시함
  • 병렬화와 사전 준비 덕분에 peak에서 Claude는 분당 약 1,300줄 코드를 작성함
  • 포트 브랜치의 merge 제외 커밋은 6,502개, peak hour는 695 commits였고, 최종 landed diff는 +1,009,272줄임
  • EC2 인스턴스의 기본 IOPS를 늘리지 않아 느린 grep 하나로 디스크 읽기·쓰기가 몇 분간 멈추는 문제도 있었음

컴파일 오류와 crate 분리

  • 모든 코드를 작성한 뒤 Claude 워크플로우가 컴파일러 오류를 수정함
  • Zig 코드베이스는 사실상 하나의 compilation unit이었고, Rust 코드는 더 빠른 컴파일을 위해 약 100개 crate로 나누려 했음
  • 가장 까다로운 오류 범주는 순환 의존성이었음
    • Rust 재작성 직전의 crate 분리 PR만으로는 충분하지 않았음
    • 별도 워크플로우가 순환 의존성이 있는 코드를 어디에 둘지 분류하고 기록함
    • 또 다른 워크플로우가 해당 리팩터링을 수행함
  • 순환 의존성 해결 후 약 16,000개 컴파일러 오류가 드러남
  • 이 오류들은 crate별로 병렬 처리됨
    • 각 crate에서 cargo check를 실행함
    • 출력을 파일별로 묶어 저장함
    • 해당 crate의 컴파일 오류를 수정함
    • 적대적 리뷰어 2명이 변경을 검토함
    • fixer 1명이 수정사항을 적용함
  • 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 실행 이틀 뒤 실패 테스트 파일은 972개에서 23개로 줄었고, 그로부터 하루 반 뒤 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에 전념할 만큼의 확신을 얻은 상태였음

테스트 규모와 비용

  • 11일 동안 May 3부터 May 14 merge까지 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개
  • pre-merge 작업에는 uncached input token 59억, output token 6억 9천만, cached input token read 720억이 사용됨
  • API 가격 기준 비용은 약 16만 5천 달러
  • 사람이 직접 했다면 코드베이스 전체 context를 가진 엔지니어 3명이 약 1년 걸렸을 것으로 평가됨
  • 사용 모델은 pre-release Claude Fable 5이고, Bun은 2025년 12월 Anthropic에 인수됐다는 disclosure가 포함됨

보안 리뷰, 퍼징, unsafe 현황

  • Rust 포트 merge 이후 Claude Code Security11라운드 보안 리뷰를 완료하고 findings를 처리함
  • Bun의 모든 parser에 대해 24/7 커버리지 기반 퍼징이 추가됨
    • JavaScript
    • TypeScript
    • JSX
    • CSS
    • JSON5
    • JSONC
    • TOML
    • YAML
    • Markdown
    • INI
    • Bun Shell scripts
    • semver ranges
    • .patch files
    • CSS colors
  • fuzzer는 찾은 버그를 Claude에 보내 재현과 수정을 포함한 PR을 제출하게 하고, 사람은 PR을 리뷰함
  • 지금까지 parser 실행은 1,000억 회였고 약 15개 PR로 이어짐
  • 작성 시점 기준 Rust 코드의 약 4%가 unsafe block 안에 있음
    • 약 13,000개의 unsafe keyword
    • 약 27,000줄 / 전체 약 780,000줄
    • unsafe block의 78%는 한 줄짜리이며, C++에서 온 포인터 또는 C 라이브러리 호출임
  • JavaScriptCore 같은 C/C++ 라이브러리를 계속 사용하기 때문에 순수 Rust 프로젝트보다 unsafe는 항상 더 많을 것이라고 밝힘

Rust 전환 후 발견된 regression

  • Rust rewrite는 대규모 변경이어서 19개의 알려진 regression을 만들었고, 모두 수정됨
  • 대부분은 두 언어에서 문법은 비슷하지만 의미가 다른 코드에서 나옴
  • debug_assert! 안의 side effect

    • Zig의 assert는 함수라 인자가 모든 build에서 실행됨
    • Rust의 debug_assert!는 macro라 release build에서는 전체 표현식이 제거됨
    • insert_stale 호출이 release build에서 사라져 React를 사용하는 HTML route 프로젝트의 특정 HMR 케이스가 깨짐
    • 관련 이슈: #30678
  • 홀수 길이 slice

    • Bun의 Zig helper reinterpretSlice(u16, bytes)는 @divTrunc를 사용해 trailing odd byte를 무시했음
    • Rust의 bytemuck::cast_slice는 홀수 길이에서 panic함
    • UTF-16 BOM 뒤에 홀수 바이트가 오는 Blob.text()가 문자열을 반환하지 않고 process를 panic시키는 regression이 있었음
    • 수정은 &buf[..buf.len() & !1]로 odd byte를 다시 무시하는 방식임
    • 관련 이슈: #31188
  • Bounds checks

    • macOS와 Linux의 Zig 코드는 ReleaseFast로 컴파일돼 bounds check가 제거됐고, Rust release build는 bounds check를 유지함
    • Bun module resolver의 overflow block 크기가 placeholder 64로 남아 ceiling이 840만 interned filenames에서 270,272로 낮아짐
    • port된 ptrs[4095] off-by-one이 실제 프로젝트에서 도달 가능해졌고, Rust는 out-of-bounds write 대신 panic함
    • 관련 이슈: #31503
  • comptime format 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과 trailing <r> marker가 충돌해 r이 텍스트로 출력됨
    • Rust에서는 macro bun_core::pretty!("<r>{}<r>", hyperlink)가 필요했음
    • 관련 이슈: #30693

수정된 버그와 메모리 누수

  • 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 프로젝트를 한 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 |

바이너리 크기, 스택 사용량, 성능

  • 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.end intrinsic을 내보내 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 public beta를 출시함
  • 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이 연결됨
  • 한 명의 엔지니어가 Fable과 Claude Code를 면밀히 모니터링해, 전체 테스트 스위트가 모든 플랫폼에서 통과하는 상태까지 11일 만에 도달함
Read Entire Article