BoaのTest262を回していたら、たまにテストが終了コード134で落ちました。

ログには、こんなエラーが出ています。

malloc(): unsorted double linked list corrupted

Issueは通常Test262のrelease実行がallocator整合性エラーで中断するです。

最初は、最近入った変更のどこかでメモリを壊しているのだろうと思いました。ただ、Test262は並列に大量のテストを実行するので、ログだけではどのテストが原因なのか分かりません。

まずは変更前後を比べる

最初にやったのは、変更前と変更後の比較です。

Boaの変更前revisionと、問題が見つかったrevisionをそれぞれreleaseビルドして、同じ条件でTest262を実行しました。

Ubuntu x86_64
release build
2 MiB worker stack
4 workers

この条件では、変更前後ともに50,595ケースを最後まで実行できました。

47,606 passed
2,056 ignored
933 failed
0 panic

allocatorの異常終了も再現しませんでした。

実行ファイルのSHA256も一致していたので、少なくともこの実行結果からは、別の変更によって壊れたとは言えません。

ただ、問題のCIでは2回続けて終了134になっています。再実行が通ったからといって、問題が解決したとは扱えません。

小さいケースに絞る

ログを見ていると、落ちる直前には次のようなsuiteが実行されていました。

  • GeneratorFunction/prototype
  • classのasync private method

どちらもPromiseやasync処理に関係しています。

そこで、全Test262を何度も回すのではなく、Promiseの解決処理でGCが動く条件を小さいテストにしました。

問題になったのは、Promiseの解決中にresolverejectを取り出し、そのあとでthen getterを呼ぶような処理です。getterの実行中には、ユーザーコードが動く可能性があります。そこではGCも発生します。

Promiseへの参照が消えていた

Promiseの処理では、内部の共有captureからresolverejectを取り出します。

このとき、Promiseを追跡可能な参照として保持している場所まで一緒に失われていました。

通常はすぐに問題が起きません。処理が短く、GCも動かなければ、たまたまメモリ上に残った状態で処理が進むからです。

しかし、そのあとにthen getterやhost hookを呼び出すと、ユーザーコードが実行されます。その間にGCが走ると、Promiseが不要なオブジェクトだと判断され、回収される可能性があります。

その状態で後続処理がPromiseを使おうとすると、解放された領域を参照することになります。

最終的に見えていたのが、allocatorの内部データ構造が壊れたというエラーでした。

malloc(): unsorted double linked list corrupted

エラーがmallocから出ているので、最初はヒープのどこかを直接壊しているように見えます。実際には、GCによる回収と参照の保持が正しく対応していないことが入口でした。

回帰テストで再現する

修正前のrevisionに回帰テストだけを追加して実行すると、ARM64 LinuxでSIGSEGVを再現できました。

このテストは、次のような条件を含めています。

  • then getterがcallableを返す
  • then getterがcallableではない値を返す
  • then getterが例外を投げる
  • Promise解決中に再入してresolverejectを呼ぶ

単純にPromiseが解決するだけなら問題が出ないので、getterの呼び出しとGCが入り得る箇所を組み合わせる必要がありました。

処理中はrootとして保持する

Boa PR #86では、Promiseを処理中のrootとして保持するようにしました。

resolverejectを共有captureから取り出したあとも、then getter、host hook、jobの生成が終わるまではPromiseが追跡対象から外れないようにします。

イメージとしては、次のような違いです。

修正前:
Promise -> resolve/rejectを取り出す
       -> Promiseへの保持がなくなる
       -> getter実行中にGC

修正後:
Promise -> resolve/rejectを取り出す
       -> 処理中はrootで保持
       -> getterやjob生成が終わる
       -> rootを解放

GCがいつ動くかに依存せず、処理中に必要なオブジェクトが生きている状態を作る修正です。

修正後の確認

修正後は、回帰テストに加えてengineのテスト、build、strict Clippy、format確認が通りました。

さらに、修正前後でTest262を比較しました。修正後のreleaseとASANは、どちらも50,595ケースを完走し、panicは0でした。

修正後ASANではメモリエラーも発生していません。

ASAN:   47,605 passed / 2,056 ignored / 934 failed / 0 panic
release:47,606 passed / 2,056 ignored / 933 failed / 0 panic

ASANとreleaseの失敗数には1件差がありますが、これは既存の適合性失敗によるものです。今回の修正でTest262の失敗がすべて解消したという意味ではありません。

全体の実行結果だけでなく、修正前の再現テストが修正後に通ること、ASANでメモリエラーが出ないことも確認しています。

まとめ

今回の問題は、Test262の並列実行中にたまに発生するallocatorエラーとして見えていました。

変更前後の全体比較では再現せず、ログからPromiseの解決処理へ対象を絞り、GCが動くthen getterを含む小さなテストでSIGSEGVを再現しました。

原因は、共有captureからresolverejectを取り出したあと、後続処理が終わるまでPromiseを保持できていなかったことでした。

修正自体は、処理中のPromiseをrootとして保持するというものです。

メモリ破壊に見えるエラーでも、実際には「必要なオブジェクトをどの期間rootとして保持するか」というGCとの境界の問題でした。

今回のような問題は、通常のテストを何度も再実行するだけでは見つけにくいので、再現条件を小さくして、修正前のrevisionで落ちるテストを作るところまで進める必要がありました。