Finding and Fixing Ghostty's Largest Memory Leak

Mitchell Hashimoto

Ghostty史上最大のメモリリークの発見と修正

原文は Mitchell Hashimoto により に公開されました。 このブログを購読する

数ヶ月前、Ghosttyが異常な量のメモリを消費しているとの報告がユーザーから寄せられ始め、あるユーザーからは10日間の稼働で37 GBに達したという報告もありました。本日、修正が見つかりマージされたことをお知らせできて嬉しく思います。この記事では、リークの原因、Ghosttyの内部構造の一部、そしてどのように原因を突き止めたのかを概観します。1

リークは少なくともGhostty 1.0の頃から存在していましたが、大規模にリークを引き起こす条件を作り出す人気のCLIアプリケーション(特にClaude Code)が登場したのはごく最近のことです。リークを引き起こす条件が限定的だったことが、診断を特に困難にしていました。

修正はすでにマージされており、tip/nightlyリリースで利用可能です。3月に予定されている正式な1.3リリースにも含まれます。


PageList

バグを理解するには、まずGhosttyがターミナルのメモリをどのように管理しているかを理解する必要があります。Ghosttyはターミナルの内容を保存するためにPageListというデータ構造を使用しています。PageListは、ターミナルの内容(文字、スタイル、ハイパーリンクなど)を格納するメモリページの双方向リンクリストです。

PageList: メモリページの双方向リンクリスト

Page 1 最古のスクロールバック ↔ Page 2 ↔ Page 3 ↔ Page 4 最新のアクティブスクリーン

ここで言う「ページ」は単一の仮想メモリページではなく、ページ境界にアラインされた連続したメモリブロックであり、システムページの偶数倍のサイズで構成されています。2

これらのページはmmapを使って確保されます。mmapは決して高速ではないため、絶えずシステムコールを呼び出すことを避けるために、メモリプールを利用しています。新しいページが必要になればプールから取得し、使い終わったページは再利用のためにプールに返却します。

プールではページに標準サイズを採用しています。標準サイズの宅配箱を購入するようなものだと考えてください。発送される荷物のほとんどは標準サイズの箱に収まり、標準化することでさまざまな効率が得られます。

しかし、時にはターミナルが標準ページよりも多くのメモリを必要とすることもあります。ある行の集合に多くの絵文字、スタイル、ハイパーリンクが含まれている場合、より大きなページが必要になります。そういった場合には、プールを完全にバイパスして、mmapで直接非標準ページを確保します。通常、これは稀なケースです。

2種類のページ確保方法

標準ページ(プール経由)

• 固定サイズ

• 解放時はプールに返却

• 将来の確保で再利用可能

非標準ページ(mmapで直接確保)

• 可変サイズ(標準より大きい)

• 解放にはmunmapの呼び出しが必要

• 再利用不可

ページを「解放」する際は、シンプルなロジックを適用しています。

  1. ページが<= standard sizeの場合:プールに返却する
  2. ページが> standard sizeの場合:munmapを呼び出して解放する

以上がGhosttyにおけるターミナルメモリ管理の基本的な背景であり、この考え方自体は健全です。次に見ていくように、リークを生んだのは最適化にまつわるロジックのバグでした。


スクロールバックの最適化

バグを理解するために、もう一つ押さえておくべき背景があります。スクロールバックの枝刈り(pruning)です。

Ghosttyには保持する履歴の量に上限を設けるscrollback-limitという設定があります。この上限に達すると、メモリを解放するためにスクロールバックバッファ内の最も古いページを削除します。

しかし、この処理は非常に頻繁に実行されるホットパスで発生することが多く(例えば大量のデータを高速に出力する場合など)、プールを使ったとしてもメモリページの確保と解放はコストがかかります。そこで、最適化として、上限に達した際に最も古いページを最新のページとして再利用するようにしています。

スクロールバックの枝刈り:最も古いページの再利用

処理前:スクロールバック上限に達した状態

Page 1 枝刈り対象 ↔ Page 2 ↔ Page 3 ↔ Page 4

先頭から取り除き、末尾で再利用

処理後:ページを末尾で再利用

Page 2 現在の最古 ↔ Page 3 ↔ Page 4 ↔ Page 1 再利用!

この最適化は非常にうまく機能します。確保は一切不要で、リストの先頭から末尾へページを移動させるために素早いポインタ操作を行うだけです。ページを「クリア」するために若干のメタデータのクリーンアップは行いますが、それ以外は以前のメモリはそのまま残します。

高速であり、経験的にもスクロールバックを多用するワークロードを大幅に高速化します。


バグ

スクロールバック枝刈りの最適化の際、私たちは常にページを標準サイズに戻すリサイズを行っていました。しかし、実際にリサイズしていたのはメタデータ上の記録だけで、基盤となるメモリ確保自体はリサイズしていませんでした。基盤となるメモリは依然として大きな非標準のmmap確保のままでしたが、PageList側では標準サイズになったと認識していたのです。

メタデータの不整合がリークを引き起こす仕組み

1 非標準ページを確保 メタデータ: 2× std mmap: std +extra

2 スクロールバックが枝刈りして再利用 メタデータ: std_size mmap: std +extra バグ:メタデータはstd_sizeにリセットされたが、mmapは変わらず!

3 ページを解放 メタデータ: std_size mmap: リーク std_size、プール由来と判断。munmapは呼ばれない!

標準 非標準 リーク

やがて、さまざまな状況(例えばユーザーがターミナルを閉じたときなど)でそのページを解放することになります。その際に、ページメモリが標準サイズ以内であることを確認し、プール由来のものだと判断して、munmapを呼び出すことが二度となくなるのです。典型的なリークです。

こうして見ると至極明白に思えますが、問題は非標準ページが設計上稀であることです。私たちの設計と最適化の目標は、標準ページを一般的なケースとして高速パスを提供することにあります。非標準ページが生成されるのは非常に限られたシナリオだけで、通常は大量に生成されることもありません。

しかし、Claude Codeの台頭がこの状況を変えました。何らかの理由で、Claude CodeのCLIは多数のマルチコードポイントの書記素出力を生成し、Ghosttyに非標準ページを頻繁に使わせることになります。さらに、Claude Codeはプライマリスクリーンを使用し、大量のスクロールバック出力を生成します。これらが組み合わさることで、リークを大量に引き起こすパーフェクトストームが生まれたのです。

はっきり述べておきたいのは、このバグはClaude Codeのせいではないということです。Claude Codeは、単にこの長年存在していたバグを露わにするような形でGhosttyを利用しているに過ぎません。


修正

修正の考え方はシンプルです。非標準ページは決して再利用しないということです。スクロールバックの枝刈り中に非標準ページに遭遇した場合は、それを適切に破棄し(munmapを呼び出し)、プールから新しい標準サイズのページを確保します。

修正の核心は以下のスニペットにありますが、その他いくつかの管理処理の修正も必要でした。

if (first.data.memory.len > std_size) {
    self.destroyNode(first);
    break :prune;
}

非標準ページを再利用し、大きなメモリサイズをそのまま保持するという手もありましたが、そうでないことを示すデータが得られるまでは、標準ページが一般的なケースであり、標準のプールされたページに戻すのが理にかなっているという前提で運用しています。

他のユーザーからはより複雑な戦略(例えば非標準ページがどれくらいの頻度で使われるかのメトリクスを保持し、それに応じて前提を調整するなど)も提案されていますが、そうした変更を行う前にはさらなる調査が必要です。今回の変更はシンプルで、バグを修正し、現在の前提とも整合しています。


VMタグでリークを発見する

修正の一環として、Machカーネルが提供するmacOSの仮想メモリタグのサポートを追加しました。これにより、PageListのメモリ確保に特定の識別子でタグ付けできるようになり、さまざまなツール上で識別できるようになります。

inline fn pageAllocator() Allocator {
    // In tests we use our testing allocator so we can detect leaks.
    if (builtin.is_test) return std.testing.allocator;

    // On non-macOS we use our standard Zig page allocator.
    if (!builtin.target.os.tag.isDarwin()) return std.heap.page_allocator;

    // On macOS we want to tag our memory so we can assign it to our
    // core terminal usage.
    const mach = @import("../os/mach.zig");
    return mach.taggedPageAllocator(.application_specific_1);
}

これにより、macOSでメモリをデバッグする際に、GhosttyのPageListのメモリが他のすべてと一緒くたにされるのではなく、特定のタグ付きで表示されるようになりました。これのおかげで、リークを特定し、PageListと関連付け、さらにタグ付けされたメモリが適切に解放されることを観察して修正が機能していることを検証することが容易になりました。


Ghosttyでのリーク防止

Ghosttyプロジェクトでは、メモリリークを見つけ、防止するために多くの取り組みを行っています。

  • デバッグビルドやユニットテストでは、リーク検出機能を持つZigアロケータを使用しています。
  • CIでは、コミットごとに全ユニットテストスイートに対してvalgrindを実行し、リークだけでなく未定義のメモリ使用なども検出しています。
  • macOS GUIを定期的にmacOS Instrumentsで実行し、特にSwiftコードベースでのリークを探しています。
  • GTK関連のPRはすべてValgrind(完全なGUI)で実行し、ユニットテストされていないGTKのコードパスでのリークを探しています。

これまでのところ、この取り組みは非常にうまく機能してきましたが、残念ながら今回のリークは非常に限られた条件下でのみ発生し、テストでは再現できていなかったため検出できませんでした。マージされたPRには、このリークを再現して将来の再発を防ぐためのテストが含まれています。


結論

これはGhosttyでこれまでに知られている最大のメモリリークであり、複数のユーザーによって確認された唯一の報告されたリークでした。今後もメモリに関する報告を引き続き監視し、対応していきますが、メモリリークの診断と修正には再現が鍵であることを覚えておいてください!

@grishy氏には、信頼性の高い再現手順をようやく提供してくれ、私自身が問題を分析できるようにしてくれたことに心から感謝します。彼自身の分析も私と同じ結論に達しており、その再現手順のおかげで私たち双方の理解を個別に検証することができました。

また、詳細な診断情報とともにこの問題を報告してくれたすべての方々にも感謝します。コミュニティによる分析、特にfootprintの出力やVMリージョン数のカウントに関する分析は、PageListが原因であることを示す重要な手がかりとなりました。

脚注

  1. この記事はAIを使わずに執筆されました。図の一部作成にはAIの支援を使いましたが、すべて人間が正確性を確認しています。本文のテキストはAIによって生成されたものではありません。

  2. この理由は本記事において重要ではありませんが、それ自体として興味深い詳細です。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント