Ghostty史上最大のメモリリークの発見と修正
数か月前から、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の呼び出しが必要
• 再利用不可
ページを「解放」する際は、次のようなシンプルなロジックに従います。
- ページが
<= standard sizeの場合:プールに返却する - ページが
> standard sizeの場合:munmapを呼び出して解放する
以上がGhosttyにおける端末メモリ管理の基本的な仕組みです。考え方自体は健全でした。この後に見るように、リークを生んだのは最適化にまつわるロジックのバグでした。
スクロールバックの最適化
バグを理解するために、もう一つ押さえておくべき背景があります。スクロールバックの刈り込み(pruning)です。
Ghosttyには、保持する履歴の上限を決めるscrollback-limitという設定があります。この上限に達すると、スクロールバックバッファ内の最も古いページを削除してメモリを解放します。
しかし、この処理は非常に頻繁に実行されるホットパスで発生することが多く(たとえば大量のデータを一気に表示する場合など)、プールを使ってもメモリページの確保や解放にはコストがかかります。そこで、次のような最適化を導入しています。上限に達した際に、最も古いページを最新のページとして再利用するのです。
スクロールバックの刈り込み:最も古いページの再利用
Before:スクロールバック上限到達時
Page 1 刈り込み対象 ↔ Page 2 ↔ Page 3 ↔ Page 4
先頭から取り外し、末尾で再利用
After:末尾でページを再利用
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: LEAKED std_size、プール由来と誤認。munmapは呼ばれない!
標準 非標準 リーク
やがて、さまざまなタイミング(たとえばユーザーが端末を閉じたときなど)でそのページを解放することになります。そのとき、ページのメモリが標準サイズ以内だと判断してプール由来のものと思い込み、決してmunmapを呼び出すことはありませんでした。典型的なリークです。
こうして見ると一見単純な話に思えますが、問題は非標準ページがそもそも設計上稀な存在だという点です。私たちの設計や最適化の狙いは、標準ページを一般的なケースとして高速なパスを提供することにあります。非標準ページが生まれるのはごく限られた状況だけで、大量に発生することも通常はありません。
しかし、Claude Codeの台頭が状況を変えました。理由は定かではありませんが、Claude CodeのCLIは多くの複数コードポイントからなる書記素(grapheme)を出力し、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さんには、安定して再現できる手順を最終的に提供していただき、自ら問題を分析することができました。心より感謝します。grishyさん自身の分析も私と同じ結論に達しており、その再現手順のおかげで私たち双方の理解をそれぞれ独立して検証できました。
また、詳細な診断情報とともにこの問題を報告してくださったすべての皆さまにも感謝します。コミュニティによる分析、とりわけfootprintの出力やVMリージョン数のカウントに関する考察は、PageListが原因であることを示す重要な手がかりとなりました。
脚注
記事をランダムに読む