ブログに戻る
compilerGCdebuggingNode.js paritymilestone

Claude Code をコンパイルする — 1つの minify 済みバンドル、160件のコンパイラ修正

6月16日、指示はたった一文でした。“claude code のフォルダを見つけて、そこにある(コンパイル済み、minify 済みの)javascript を持ってきて…コンパイルできるか試してみよう :D” 案の定「それは過酷だ」という反論が返ってくると、答えは本当のテーゼでした。“過酷だけど、それこそ本当の意味での実力試しであり壁チェックだから、やりたいんだ。限界を見つけるという点で、実世界のアプリに勝るものはない。”

1か月後、Anthropic の Claude Code CLI を Perry でコンパイルしたバイナリは起動し、/login で OAuth フローを通し、API から本物のレスポンスをストリームし、入力した文字を描画するようになりました。そこに至るまでに 6月20日から7月17日の間に160件のプルリクエストが Perry にマージされました — no-op だった MessageChannel、RegExp ヘッダーに欠けていた GC の write barrier、本物の API に対してだけ空回りしモックでは決して起きなかった continue 文、そしてさらに約150件。

この投稿はそのツアーです。他人の CLI をコンパイルすることが製品になるからではありません — このバイナリを出荷することはありませんし、今後もありません — そうではなく、これが私たちがこれまで Perry に向けてきた中で、最も生産的なバグ発見装置だからです。

/tmp/verify から Perry でコンパイルされた Claude Code バイナリを実行している macOS のターミナル。v2.1.112 のバナー、成功した /login、「awesome, who are you?」というプロンプト、ストリームされた返信、そしてシェルへのクリーンな終了が表示されている。
そのコマンドラインに node はありません。/tmp/verify/cc_fptest_dbg25 は、出荷された cli.js から perry compile によって生成された単一のネイティブ実行ファイルです — ログインし、本物の回答をストリームし、Ctrl-C でクリーンに終了しています。ファイル名がその疑問を誘うので説明しておくと、dbg25 は、これを書いている時点でまだ進行中の GC 調査の診断シリーズの25番目のビルドです — デバッグシンボル付き、さらに write-barrier のエリジョンをオフにしてすべての配列ストアがバリアを発行するようにしています。通常のビルドより、オーバーヘッドは少ないどころかむしろ多いのです。この先の性能表は、別の、計測用の仕込みがないバイナリで計測したものです。

「Claude Code をコンパイルする」とは実際どういうことか

対象は npm が出荷する成果物です。npm pack @anthropic-ai/claude-code@2.1.112 を実行すると cli.js が手に入ります。13 MB の minify 済み、自己実行型の JavaScript で、#!/usr/bin/env node という shebang が付いています。ソースもソースマップもなく、私たち側のビルドステップもありません。私たちはそのファイルを一切変更せずに perry compile にかけ、ネイティブ実行ファイルを要求します。

Perry はそれを約37分かけて処理し、16,023個の関数にわたっておよそ207 MBの IR を生成し、それらは約180 MBのバイナリにリンクされます。それらの関数はどれも1文字の名前しか持たず、型注釈もなく、しかも全体が ahead-of-time で動作しなければなりません — JIT はなく、eval もなく、コンパイラの推測が外れたときにインタプリタへ遅延フォールバックする仕組みもありません。もし Perry が16,023個の関数のうち1つでも誤って lower してしまったら、それを捕まえるものは何もありません。

スコアリングの段階は私たちの社内ストレススイートから来ていて、意図的に容赦のないものになっています:

parse    → perry couldn't even parse it
compile  → parsed, but HIR/codegen errored
link     → codegen ok, but cc/ld failed
run      → linked, but the binary crashed / hung / exited non-zero
ran-ok   → binary exited 0
correct  → output byte-matches node --experimental-strip-types

correct だけが意味を持つ層です。Node v26 がオラクルであり、Node が出力するものとバイト単位で一致しないものは、そうではないと証明されるまでは Perry のバグです。

なぜこのアプリだったのか

コーディングエージェントの CLI は、ahead-of-time でコンパイルするには異例なほど手強い JavaScript の塊です。1つのバイナリの中に、Ink を通してターミナルへ reconcile する React、raw モードの stdin リーダー、毎フレーム正規表現を走らせる ANSI/絵文字だらけのレンダラー、ストリーミング SSE の HTTP クライアント、起動時に構築される zod スキーマ、OAuth フロー、worker_threads、マクロタスクスケジューラとして使われる MessageChannel、fiber の状態を保持する WeakMap、動的な require、そしてファイルディスクリプタ経由で stdout に書き込むファイルシステム層が詰め込まれています。

それぞれが Perry の異なるサブシステムに対応していて、このアプリはそれらを、規模を伴い GC の圧力の下で、数分間にわたって同時に酷使します。私たち自身のテストスイート — 3,000のRustユニットテスト、数千のTypeScriptリグレッションプログラム、Node API 互換性マトリクス、test262 — はすべて既知の挙動を固定することに向けられています。このバンドルは、誰も固定しようと思ったことすらない挙動を見つけることに向いています。

壁の連鎖

作業は一方向にしか進みませんでした。今の壁を越え、次の壁を見つける。それを圧縮すると:

日付マイルストーン
6月21日--help がネイティブに実行され、終了コードは0
6月22日実際のサブコマンドが起動時にハングしなくなる
6月23日-p が api.anthropic.com への ESTABLISHED な TCP ソケットを開く
6月24日zod スキーマが正しく構築され、認証パスに到達
6月27日TUI がレンダリングされる — ロゴ、ウェルカムボックス、入力フレーム
7月9日初めての完全な往復: 本物の API に対する -p が返信を出力し、終了コード0
7月10日Node と Perry の差分ハーネス: 12/12 が一致
7月13日-p のテキスト + JSON、TUI のレンダリング、ファイルシステムで Node とバイト単位で一致
7月16日入力した文字がついに入力行に表示されるようになる
7月17日手作業でループ全体を検証: 起動 → /login → API のレスポンス → 入力

すべての修正は、最小限で一般的な再現手順を添えた独立したプルリクエストとして upstream に送られました。どれも、由来元のアプリには言及していません — それは初日からのルールでした。changelog を読む Perry ユーザーの目に映るのは「for-await ドライバの continue がイテレータの advance をスキップしていた」であって、「誰かの CLI をコンパイルしようとしていた」ではありません。バグは、それを表面化させた乗り物とは無関係に、実在するものです。

紹介する価値のある5つのバグ

1. MessageChannel は行儀のいい no-op だった

--help は6月21日に動くようになりました。本物のサブコマンド — doctoragentsmcp list — はどれも永遠にハングしました。busy 状態ではなく、sample ではプロセスが停止したまま park しているのが見え、lsof は子プロセスなし、ソケットなし、パイプが2本あるだけでした。

Perry の MessageChannelpostMessage を no-op として、onmessagenull としてインストールしていました。それは、メッセージチャンネルをマクロタスクスケジューラとして使う React スケジューラのパターンに出会うまでは無害です:

const ch = new MessageChannel();
ch.port1.onmessage = flushWork;
ch.port2.postMessage(null);   // schedule the next tick

メッセージは捨てられ、コールバックは一度も実行されず、イベントループは自分自身の wakeup パイプの上で永遠にアイドルし続けました。#5530 は、ポートに本物の同一スレッド内配送を与えました — エンタングルされたペア、FIFO キュー、setImmediate のマクロタスクを介した配送、そしてキューに入ったメッセージがコレクションを生き延びられるようにする GC のルートスキャナです。

2. Object.prototype 上のアクセサ1つが42秒のコストになった

ハングする前は、同じサブコマンドが数十秒間 CPU バウンドになっていました。プロファイリングは汎用の [[Set]] パスを指しており、根本原因はプロセスグローバルなフラグでした。

Perry には動的なプロパティ書き込みのための高速パスがあり、「Object.prototype は現在何らかのディスクリプタを持っているか?」という条件でゲートされています。このバンドルは起動時に Object.prototype にちょうど1つのアクセサをインストールします。それによりフラグはプロセス全体で反転し、以降はプログラム内のすべての動的な書き込みが O(own-key-count) の遅い割り込みウォークを取るようになりました。幅の広いオブジェクトを構築する処理は二次時間になりました:

20,000-property build, clean process:                 16 ms
20,000-property build, after one Object.prototype accessor:  42,394 ms

#5524 は、グローバルなフラグをキーごとの問いに置き換えました — Object.prototypeこのキーに対する own プロパティを持っているか? というものです。存在しないキーは割り込まれようがないので、その書き込みは高速パスにしても安全です。42 s → 23 ms、しかも割り込みは依然として正しく動作します。

その後、同じ形状がクラスインスタンスに対して再び現れました。高速パスはそれを完全に除外していたのです。20,000キーのビルドは、プレーンオブジェクトでは25 msだったのに対し、class インスタンスでは44秒かかりました。この修正を丁寧に行うことには意味がありました — 素朴なプロトタイプチェーンのチェックでは継承された setter を見落とし、データを黙って破損させていたはずだからです — そこで #5528 は、クラスレジストリを通じてインスタンスのプロトタイプを解決し、O(1) の wide-key インデックスを追加しました。30 msに戻り、再び線形になりました。

3. 本物の API でしか再現しないバグ

これは私たちが人に話したがるバグです。7月初旬までに、コンパイル済みバイナリは私たちのローカルモックサーバーに対して完全な -p のやり取りをこなせるようになっていました — 接続、POST、SSE ストリームの読み取り、返信の出力、終了コード0。しかし実際の Anthropic API に対しては、毎回、永遠にハングしました。

デバッグの連鎖はこうでした:フォワードプロキシのモックが、完全な200レスポンスが無傷で届いていることを証明する → キャプチャした本物のバイトストリームを与えたリプレイモックが、ローカルでハングを再現する → SSE イベントリストを二分探索して原因のイベントを見つける → 10行の再現コード。

本物の API は event: ping フレームを送ってきます。私たちのモックは一度もそれを送りませんでした。そして ping は、SDK のストリームループが素の continue でスキップする唯一のイベントです。Perry は for await を、イテレータの advance がループ本体の末尾にあるドライバへと lower していました:

// what perry emitted
while (!done) {
  ...body...                   // a "continue" here skips the advance…
  result = await it.next();    // …so this never runs. Spin forever.
}

// what it emits now
while (true) {
  result = await it.next();
  if (result.done) break;
  ...body...
}

6つの別々の lowering 箇所が、同じ形状を持っていました。#6196 は、そのすべてで advance を先頭に移動しました。私たちが何度も学び直す教訓はこうです:パスするモックは、モックについての証拠でしかない。

4. 自分自身のパターン文字列より長生きした正規表現

TUI はレンダリングされたあと、数秒以内に SIGBUS で死にました — ウィンドウリサイズのストレスハーネスの下で、12回の実行中12回クラッシュし、しかも毎回異なる関数の異なるアドレスでした。調査の何週間もが、A/Bテストによって後に反証される GC の仮説につぎ込まれ、その中には不健全だと判明して撤回せざるを得なかった、私たち自身の「修正」の1つも含まれていました。

実際の根本原因は、Rust のコード4行でした。js_regexp_new は RegExp のヘッダーをアロケートし、その patternflags の文字列ポインタを生の書き込みで格納します — write barrier なしで。old 世代のオブジェクトが、生まれたての young な文字列を指しているのに、コレクタはそのエッジについて一度も知らされません。マイナー GC はそれらの文字列を、生きている RegExp の足元から掃き出してしまい、次に解放済みスロットを読んだときにフォールトしました。

なぜこれがここでしか現れなかったのか? ターミナル UI は正規表現だらけだからです — ANSI のパースと絵文字の幅の計測が、毎フレームパターンを走らせます — そのため、アロケーションとコレクションの間のウィンドウが1分間に何千回も横切られます。私たちの最小再現コード、6,000個の正規表現に意図的なアロケーションの churn を加えたものは、一度もこれを引き起こしませんでした。バンドルは毎回それを引き起こしました。#6288 は、両方のフィールドがずっと必要としていたバリアを追加しました。

5. 入力した文字は確かにそこにあった。フレームがそれを捨てていた。

最も頑固な壁でした。UI 全体は完璧に描画されていました — ウェルカムボックス、入力フレーム、カーソルブロック、ステータスライン。/ を入力するとコマンドメニューが開いたので、キー入力は React に届いていました。しかし文字が入力行に表示されることは決してありませんでした。

計測用の仕込みを入れたビルドでは、Perry がすべての段階で入力文字を正しく描画したあと、onRender が描画の後で例外を投げていることが分かりました — それを Ink の try/catch が飲み込んでいたのです。フレームはコミットされる前に破棄され、その後のすべてのレンダリングは空のフレームの上に積み重なりました。アプリは、あなたを無視しながら、完全に健全に見えていたのです。

その1つの症状の裏には、2つの独立したバグが隠れていました:

  • #6453 — Perry のインライン化された charAt/codePointAt/split の lowering は、必要な coercibility ガードなしに、文字列でない receiver に対して ToString を呼び出していました。そのため undefined.codePointAt(0) は、例外を投げる代わりに、静かに 117 を返していました — 文字列 "undefined""u" のコードポイントです。もっともらしいデータをでっち上げるバグは、クラッシュするバグよりもはるかに悪いものです。

  • #6471 — こちらが本当のブロッカーでした。配列が成長するとき、Perry は古いアドレスに恒久的な転送用スタブを残します。マイナーのスイープは、old 世代の親がまだそのスタブの1つを指しているのに、それらのスタブを回収していました。レンダラーの文字キャッシュが、dirty になっていないページ上の、成長前のポインタを保持していたのです。古びたスタブ越しに読むとガベージな length が生成され、すべてのフレームが中断していました。マイナーは現在すべてのスタブを保持するようになり、フルトレースはマークによってそれらを回収します。

それらを修正すると、第3の層が露わになりました — インクリメンタルマーキング中に black で生まれたオブジェクトは一度もトレースされておらず、それらを通してしか到達できないものは生きたまま掃かれてしまっていました(#6494)。また、レイアウトマスクがオーバーフロースロットを過少に報告していたため、コレクタがそれらをスキップしていました(#6506)。この2つは、コンパイルされたあらゆるプログラムで50回に1回の謎のクラッシュを生むタイプの健全性の穴です。テストスイートでは、これらを見つけられなかったでしょう。

13 MB の minify 済みバイナリをどうデバッグするか

上記のどれも、コードを読むだけでは見つけられません。それを扱えるものにしたツール群です:

  • Node をオラクルとする差分ハーネス。 あらゆる仮説は小さな TypeScript プログラムになり、node --experimental-strip-types と Perry のバイナリの両方の下で実行され、バイト単位で比較されます。それ自体でもバグを見つけました — instanceof の右辺としてのみ使われるクラス式が、11個の解析パスがそのノード型を見通せなかったために dead-code-eliminate されていたのです(#6245)。

  • 3つのモック API サーバー。 ロギング用のモック、本物のレスポンスのバイトをキャプチャするフォワードプロキシ、そしてそのバイトをそのまま決定論的に返すリプレイサーバーです。「本番に対してハングする」をローカルでの再現に変えたのは、このリプレイサーバーでした。

  • ターミナルの問い合わせに応答する PTY ハーネス。 何もしない疑似端末では、50バイトとストールしか得られません。このアプリはカーソル位置(ESC[6n)、デバイス属性(ESC[c)、背景色(OSC 11)を問い合わせ、描画する前に応答を待ちます。それらに答えてやれば、完全な3,331バイトのウェルカム画面が得られます — そして Node と Perry の間でバイト単位で比較可能なレンダリングも。

  • シンボル化のためのリンクマップ。 strip された180 MBのバイナリは、生のオフセットだらけのクラッシュレポートを生成します。ld64 -map の出力に二分探索スクリプトを組み合わせることで、それらを関数名に戻せます。

  • すべてを A/B する。 生まれてきたルールは、引き継ぎメモの冒頭に大文字で書かれています — 理論を鵜呑みにするな、検証しろ。 1つのバグに対する4つの連続した根本原因の仮説は、それぞれ A/B の実行によって反証されました。何日も追いかけた1つの検証シグナル(「445個の old→young エッジの欠落」)は、測定上のアーティファクトだと判明しました — そのチェックは、remembered set をクリアしてから復元するまでの間に実行されていたのです。コード自身のコメントが、それについて警告していました。

実際のところ、今どうなっているか

正直に言うと、動きますし、遅いです。

現時点で動作していて、出力が比較可能な範囲では Node とバイト単位で一致しているもの — 起動、--help--version、エラー分類と JSON エンベロープを含む本物の API に対するワンショットの -p モード、TUI の完全なレンダリング、OAuth の /login フロー、ストリーミングレスポンス、そして入力です。ループ全体をワンテイクで — 起動、/login、質問、ストリームされた回答、終了:

63秒、音声なし、2箇所をカット — OAuth ハンドシェイクのブラウザ側の部分と、脈絡のない macOS キーチェーンのプロンプトです。ターミナル内のものはすべてリアルタイムで無編集です — この投稿の中で最も遅いものである起動の遅延も含めて。

まだ未解決なことが2つあり、それらは実は1つのことかもしれません。持続的な対話的使用をおよそ1分続けると、入力が反応しなくなります — クラッシュもエラーもなく、ただ止まるのです。そして Ctrl-C は今ではクリーンに終了するようになりました(1週間前はそうではありませんでした。これが録画の最後に見える終了です)が、ESC は実行中のレスポンスを中断しません。割り込みパスが動かないのに終了パスは動くという事実は、アプリ自身のハンドラの何かがおかしいというより、キー入力イベントがアプリに届かなくなるという、入力が死ぬ現象と同じ容疑者を指しています。

パフォーマンスの全体像です。同一のバンドルを実行する Node と比較して、正しさが達成された当日に始まった速度改善キャンペーンの最初のラウンドの前後で計測しました。これらは7月17日の cc_finalcc_perf2 から得たものです — 上のスクリーンショットにあるような計測用の仕込み入りバイナリではなく、診断機能を組み込んでいない通常のビルドです:

指標NodePerry(7月17日)Perry(改善後)
--version328 ms1,168 ms227 ms
--help715 ms5,071 ms4,099 ms
TUI の初回描画0.76 s10.9 s8.4 s
キー入力 → 描画(p50)2.2 ms111–143 ms119–138 ms
メモリフットプリント(アイドル時)290 MBで横ばい~420 MBで増加中~420 MBで増加中

--version は今やNode を上回っていますが、その勝因は出どころが恥ずかしいものでした — コンパイルされた実行ファイルは300,281個のシンボルをエクスポートしていて、その起動時間の~80%は dyld が weak-definition の coalescing をしている時間だったのです。リンカのフラグを1つ変えるだけでエクスポート数は3個になり、バイナリは228 MBから197 MBになりました(#6533)。それぞれのユニークな正規表現を2回ではなく1回だけビルドするようにしたこと(#6534)と、store ごとの割り込みチェックをキャッシュしたこと(#6532#6541)が残りを片付けました。

その197 MBについて、誰かがそれを先頭に持ち出す前に言っておくと — Perry は自分自身のランタイムを静的にリンクし、16,023個すべての関数のマシンコードを ahead-of-time で出力していて、dead-strip できるものがありません。自己実行型のバンドルは基本的にすべてが到達可能になるため、頼れるようなクロスバンドルの DCE がないのです — つまり197 MBというのは、プログラム全体に加えてそのランタイムが1つのファイルに入っているということであり、それに対して Node v26 のバイナリは、あなたの JavaScript を1行も読む前に138 MBの重さがあります

キー入力の行は注意して読むべきものです。「改善後」の数値が悪化しているように見えるからです。しかし実際には悪化していません — これらは繰り返し実行した際の範囲であり、重なり合っているので、中央値はどちらの方向にも動いていません。それは回帰ではなく、実行ごとのばらつきです。そしてこれは、私たちが予想していたとおりのことでもあります。3つの変更はすべて、リンクのステップ、正規表現の構築、そしてstoreパスに触れるものでした。キー入力の中央値を支配しているのは、プロパティのreadパス、呼び出しごとの rooting のオーバーヘッド、そしてキー入力のウィンドウの中に落ちる40–80 msの GC ステップです。その証拠は次のラウンドで得られました — get/set の高速レーンはフィールドアクセスのマイクロベンチマークを3×速くしましたが、この数値はまったく動きませんでした(#6539)。アプリに現れないマイクロベンチマークの勝利は誤った診断であり、私たちはまさにそれをやってしまっていました。

メモリの行は現在進行中のキャンペーンです。Perry のコピー型コレクタはコンパクションを行うことができます — 問題は、それがアイドル状態でおよそ45秒に1回しか発動可能にならないことで、そのため発動と発動の間に nursery が ~300 MBまで再び膨らむ一方、Node は継続的にコンパクションすることでフラットなままでいることです。これはアルゴリズムの問題ではなく、トリガーの頻度の問題であり、6月に私たちがいた場所よりもずっとましな場所です。

そのどれも棚上げにはなっていません。この投稿が公開される時点でも、メモリとパフォーマンスのキャンペーンは進行中です — GC のトリガーに関する作業は、まさにこのバイナリの上で、今日も進められています — なので、上の性能表はこの投稿の中で私たちが最も無効化されることを期待している部分であり、それは早ければ早いほど良いのです。正しさを達成するには1か月分の壁が必要でした。残っているのはトリガー頻度の問題と read パスの問題で、どちらも原因は理解されていて、どちらもすでに対応が進行中です。私たちはこれが、単に正しいだけでなく、滑らかに感じられるようになると予想していますし、それも近いうちだと予想しています。

なぜ私たちはこれをやるのか

160件の修正のどれ1つとして、Claude Code についてのものではありません。RegExp ヘッダーの write barrier の欠落は、負荷の下で正規表現を構築するあらゆるプログラムのメモリを破損させます。continue を伴う for await は、あらゆるストリームの消費者の中で空回りします。MessageChannel がメッセージを取りこぼすことは、React スケジューラの形をしたあらゆるアプリを壊します。Object.prototype のディスクリプタフラグは、Object.prototype に触れるあらゆるプログラムを、その最も幅の広いオブジェクトにおいて二次時間にしていました。

それらのバグはすべて、CI が green である間も、test262 の数値が上がっていく間も、Node 互換性マトリクスが97%と言っている間も、ずっと Perry の中に潜んでいました。それらを揺さぶり出すのに必要だったのは、誰か他人の13メガバイトの minify された JavaScript が、本物のターミナルの中で本物の API に対して本物の仕事をすることでした。

もう1つあります。私たちが一番面白いと思っている部分です。Perry は手作業だけで書かれているわけではありません — その多くが、このキャンペーンの多くを含めて、Claude Code とともに書かれました。モックサーバー、PTY ハーネス、差分ランナー、朝の4時までかかった GC バグの二分探索の長い夜 — これらはエージェントセッションであり、人間によってレビューされ、マージされました。Perry のすべてがそうというわけではありませんし、多くの議論なしにというわけでもありません。しかし、この一文が両方向で真であると言えるだけの十分な量ではあります。

Claude Code を食べたコンパイラは、その相当な部分が、Claude Code によって作られたものでした。

私たちはこれを続けていきます。次のターゲットはすでにキューに入っています。


Perry は Anthropic と提携しておらず、Anthropic の支持を受けているものでもありません。Claude Code は Anthropic PBC の商標です。ここで説明されているバイナリは、公開されている npm パッケージから、純粋にコンパイラのテスト対象として構築されたものであり、配布されていません。

この記事が気に入ったら、次の記事も受け取りませんか?

Perryのリリースと次の開発内容を短くお届け。

月に数通のメール。いつでも配信停止できます。