Windows 版 WebKit のヒープ破壊のデバッグに1週間以上費やした。 https://bugs.webkit.org/show_bug.cgi?id=209847

仕事で Open Source の開発をやっていると、デバッグの比率が高く、しかも知らないコードをデバッグする必要がある。 自身のデバッグ技術と作業効率の向上ために記録を残しておく。 私は Windows 開発に詳しくはないのでもっといい方法があっただろうと思います。

当初は手許の PC 上では再現できず、自動テストサーバー上でのみ再現した。 さいわい msconfig.exe で BOOT option を変更し CPU 数を2にすると、手許の PC でも再現することができた。 さらに、BOOT option を変更しなくても Task Manger で powershell.exe を Set Affinity で CPU 数を2つに制限することでも、 その PowerShell から自動テストを起動すると再現することができた。

WebKit の自動テストスクリプトはクラッシュを検出すると自動的に ntsd.exe でクラッシュログを採取してくれる。 ExceptionCode は c0000374 (STATUS_HEAP_CORRUPTION) だった。 クラッシュする場所はランダムだが、頻繁にみるのは CreateProcess の中や Cairo の中やプロセス間通信(IPC)周辺でのメモリ確保中だった。

release mode の CRT heap でのみ発生し、CRT Debug Heap では再現することはできなかった。 自動テストスクリプトを走らせると再現するが、実行ファイルを debugger で起動して再現する方法は模索しなかったため Application Verifier を使うことはできなかった。

ヒープ破壊を起こすバグとしてよくあるのは double free, use-after-free, buffer overrun である。 CPU 数が再現率に影響しているので、 multi threading がらみの race condition や data race が疑われた。 Debug Heap で再現しないのはタイミングが変化するためだと考えられる。

WebKit では多くのオブジェクトが reference counter を用いて生存管理されている。 そして、reference counter の実装には thread safe なものと thread unsafe なものがある。 必ずひとつの thread からしか reference counter の上げ下げを行わないオブジェクトには thread unsafe な reference counter が利用されるが、 万が一、複数の thread から同時にその reference counter を decrement すると、2回 destructor が呼ばれるバグとなる。 以前にそのバグに遭遇したことがあったので、今回もそれを疑った。 (正確には thread unsafe ではなく thread-hostile と呼ぶらしい)

thread safe な reference counter には、破棄された後に reference counter が操作されないかをチェックするデバッグ用コードがあるので、 同様のことを thread unsafe な reference counter に追加した。 しかし、ヒープ破壊は起きるが、そのチェックコードにはヒットしなかった。

つぎに _heapchk() を2箇所に入れて挟み込んで発生場所を絞り込む作戦を取った。 頻繁にクラッシュしていた CreateProcess や Cairo は、そもそもそれを呼ぶ前からヒープは壊れていた。 Windows のメッセージループを回していると、壊れることがわかった。 そして、IPC 周りでよく壊れていた。 しかし、UI の thread や IPC 用 thread のメッセージループのメッセージ処理の前と後に _heapchk() を入れると、 メッセージ処理前にすでにヒープが壊れていた。 つまり、UI の thread と IPC 用 thread 以外でヒープが壊れていると考えられた。

しかし、ヒープ破壊しそうな thread は他に思い当たるものがなかった。 そこで考えたのは CRT Heap が内部で thread を生成し、回収を遅延している可能性だった。 CRT のソースはないので、検証のしようもなかった。

そこで別の allocator として、 Microsoft 製の mimallocを試した。 CRT Debug Heap では再現しなかったため心配していたが、幸い mimalloc でも再現した。 しかも、セキュリティ機能をすべて有効 (MI_SECURE=4) にしても再現した。 ドキュメントによるとセキュリティ機能にはガードページや2重開放検知が備わっているそうなので、 これで buffer overrun や double free の可能性は否定された。

次に、CRT Heap と mimalloc を組み合わせて発生場所を絞りこんでいった。 例えば WTF::Vector クラスのみ allocator を mimalloc にしたり、逆に WTF::Vector クラスのみ CRT Heap にしたり。 これで徐々に破壊される箇所が絞りこめた。 結果、IPC で非同期にパイプを ReadFile しているバッファが壊れていた。

IPC の接続を切断するときに ReadFile に渡したバッファは開放するが、 その開始した非同期 ReadFile はキャンセルしていなかった。

IPC 周りでよくクラッシュするのを見ていたが、 ReadFile を非同期に行っていることを知らなかったため、 そこに IO 用に別 thread があることに気づけなかった。

あとで気がついたが CRT Debug Heap を有効にすると異常に遅くなっていたのだが、 これは _CRTDBG_CHECK_ALWAYS_DF が有効にされていたためメモリの確保と開放の度にチェックが行われていたからであった。 これが再現しなくなる原因でだったのかもしれない。 さらに _CRTDBG_DELAY_FREE_MEM_DF を有効にすると free したメモリを 0xDD で埋めて開放を遅延するようなので、 これでヒープ破壊を検出できていたのかもしれない。