OmoikaneのWeb Platform Tests(WPT)で、フォームに関連付けられたCustom Elementのテストを動かすとSIGSEGVになった。最初に見つかったのはJITを含む構成だったので、BoaのJITかGCとの組み合わせを疑った。しかしJITを無効にしても同じクラッシュが再現し、最終的にはgetterの返したオブジェクトをVMのレジスタへ保存するまでの、わずかな区間に原因があった。

PR #1187で修正したのは、BoaのGetPropertyByNameとGetNameGlobalという二つの命令だ。プロパティを読む命令の途中でinline cacheを更新するとGCが走り、まだGCから見える場所に置かれていないgetterの返値を回収していた。この記事では、最初のWPT失敗からそこに至るまでを記録する。

以前書いたArray.from()でのGC参照保持漏れと同じく、Rustのローカル変数にJsObjectがあるだけではBoaのGCにとってrootとは限らない。ただし、今回はユーザーコードを呼ぶ前後ではなく、getterの実行が終わった後にVM内部でcacheを更新する順番が問題だった。

Mutation最適化の後にWPTが落ちた

きっかけはPR #835で試した追加のMutation最適化だった。queueMutationのinitのcloneを減らす変更と、使っていないlayout observer hookを抑える変更を同時に入れると、Gate4 compatibilityの固定WPTでcustom-elements/form-associated/form-associated-callback.htmlがsignal 11で終了した。Issue #858に最初のCIとローカル再現の結果が残っている。

最適化を入れる前のrevisionでは同じ条件を通過し、二つの変更を一つずつ適用した試験もそれぞれ成功した。一方、両方を適用すると落ちる。ひとまず追加変更はrevertして、PR #835の残りを進めた。ただし、「二つの最適化の組み合わせがクラッシュを見せた」ことと、「Mutationの処理に無効な参照を作るバグがある」ことは同じではない。割り当ての回数や順番が変わると、別の場所にあったGCの寿命問題が表面化することもある。

このテストの中身を分割すると、五つのtest()は単独では全部通った。二つ、三つ、四つを選ぶ組み合わせも通るのに、元の順序で五つ続けると落ちる。五つ目のformのid更新を外しても再現した。特定の一操作が必ず間違っているというより、それまでに積み上がった実行状態や割り当て量が発火条件になっているように見えた。この段階ではまだ仮説だ。

最初に取れたバックトレースには、BoaのJsObject::__get__、VMのGetPropertyByName、JIT trampolineが並んでいた。Gate4のjit-stress,jit-differential構成で発見したこともあり、JIT側の問題に見える。ただ、停止した場所は不正な参照を使った場所であって、その参照を不正にした場所とは限らない。

JITを止めても落ちる

調査を進めると、WPT manifestを差し替えるときの相対パスが作業ディレクトリに依存していたことが分かった。そこを合わせて、保存していた旧triggerのバイナリと同じWPTを再実行すると、五つのテストを続けたときのSIGSEGVを安定して再現できた。

ここでbaseline JITを明示的に無効にして、同じ入力をインタプリタだけで動かした。それでも3回中3回落ちた。JIT有効時に初めて観測したことは事実だが、この再現条件ではJITの機械語生成やstack mapが必要条件ではない。調査の対象はGCとインタプリタ側の値の扱いに移った。

次に、GCの閾値を変えてみた。通常の設定では5回中5回クラッシュし、major GCの閾値だけを1GiBにしても5回中5回クラッシュした。一方、nurseryの閾値だけを1GiBにすると5回中5回通った。これはnursery collectionのタイミングが強く関係するという手掛かりにはなる。しかし、この実験だけで「どのcollectionが、どのオブジェクトを回収したか」までは分からない。閾値を上げるのも根本修正ではなく、クラッシュするタイミングを遠ざけているだけだ。

getterの返値は、まだVMレジスタにいなかった

決め手になったのは、旧triggerをValgrindで追った結果だ。PR #1187の調査記録では、最初のinvalid readはGcRefCell::try_borrowで検出され、対象の割り当てはProxyオブジェクトだった。解放したminor collectionは、InlineCache::setがweak shape handleを作る途中で起きていた。

ここでGetPropertyByNameのslow pathを見ると、順番はこうなっていた。object.__get__は、通常のデータプロパティならその値を返し、getterのあるプロパティならgetterを実行して結果を返す。getterは新しいオブジェクトを作って返せる。

getterを実行し、返値をRustのローカル変数に受け取る
  -> inline cacheへプロパティの情報を登録する
     -> weak shape handleの確保でminor GCが起こり得る
  -> 返値をVMの宛先レジスタに格納する

inline cacheは、次に同じようなプロパティアクセスを行うとき、前回分かったshapeやslotを利用して探索を省くための仕組みだ。cacheそのものの更新が、getterの返値を消したいわけではない。ただ、その更新には割り当てがあり、GCが起こり得る。getterの結果を受け取ってからVMレジスタへ渡すまでに、処理がもう一つ挟まっていた。

getterが返した新しいオブジェクトが、ほかのJSオブジェクトから参照されていない場合を考える。Rustのresultというローカル変数にはJsValueがある。しかしBoaのGCは、そこに値があるというだけで外部rootとして扱わない。VMレジスタに入ればGCがたどれるが、cache更新中はまだそこにいない。cache更新がminor GCを起こすと、返値がfinalizeされてメモリから解放され得る。その後、VMは解放済みオブジェクトを指すedgeをレジスタへ書いてしまう。

getterが返した新しいProxy
        |
        v
Rustのresultだけが参照する
        |
        +---- inline cache更新中にminor GC ----> Proxyを回収
        |
        v
VMレジスタへ解放済みのedgeを書き込む
        |
        v
後のアクセスでinvalid read / SIGSEGV

この図で起きているのは、cacheにProxyを入れたという話ではない。cache更新時の別の割り当てがGCを呼び出し、その時点でrootされていない返値が巻き込まれた、という順番だ。Valgrindが示した解放と最初の不正な読み出しは、この説明と一致した。GetNameGlobalにも、グローバル名のgetterから返値を得てcacheを更新してからレジスタに入れる、同じ空白があった。

rootする範囲をレジスタ格納までに限定する

修正では、getterから得たresultがオブジェクトなら一時的にrootし、そのままcache更新を行い、レジスタへ格納してからrootを解除した。GetPropertyByName側の変更の要点は次の部分になる。

let result = object.__get__(&key, receiver.clone(), context)?;
let result_root = result.as_object().map(JsObject::root);

// この間にinline cacheを更新する

context.vm.set_register(dst.into(), result);
drop(result_root);

これはcacheをなくす修正でも、GCを停止する修正でもない。返値が一時的にRust側にしかない間だけ、GCから到達可能にする。VMレジスタに値を渡した後は、レジスタがそのedgeを保持するので、一時rootを外せる。数値などのオブジェクトでない返値には、このオブジェクト用のrootは必要ない。同じ順番だったGetNameGlobalにも同じ修正を入れている。

前のArray.from()の件では、取得したiterator関数をconstructorの実行を越えて生かす必要があった。今回はgetterから戻った直後の、cache更新からレジスタ書き込みまでが対象だ。どちらも「値を使い終わるまでrootする」では大ざっぱすぎる。どの操作がGCを起こし得るか、値をGCが追跡できる場所へ移したのはいつか、という境界を見てrootの寿命を決める必要がある。

GCがcache更新で走る回帰テスト

実際のWPTだけに頼ると、割り当てのタイミングが少し変わっただけで再現しなくなる。追加した二つの回帰テストは、globalThis.freshで読む経路と、freshというグローバル名で読む経路を分けて確かめる。

getterは新しいオブジェクトを一つ作り、その後NoGcScopeの中で、8KiBのpaddingを持つ使い捨ての割り当てを512個作る。約4MiB分を割り当てても、このscope内ではGCを実行させない。getterが戻るとNoGcScopeは終わり、続くinline cache更新の割り当てで保留されていたcollectionを起こす。つまり、問題になった「返値を受け取った後、レジスタへ書く前」という位置にGCを置くテストだ。

テストでは使い捨てオブジェクトのfinalize回数を見て、実際にcollectionが起きたことを先に確認する。その後、getterが返したオブジェクトがfinalizeされていないことを確認し、それから結果の型に触る。修正前に解放済みのオブジェクトをいきなり読むとテスト自体が不正アクセスを起こしかねないため、finalizeの記録を先に見る構成になっている。二つのテストは修正前にgetter result was collected during cache fillで失敗し、修正後に通った。

元のWPTでも確認した

修正を入れた状態で、旧triggerと同じ固定WPT revision、通常のGate4 featureを使った263ケースは263件すべて通り、regressionは0件だった。問題のWPTもインタプリタで5回中5回、baseline JITで5回中5回通過した。修正後の旧triggerをValgrindで動かした試験ではERROR SUMMARY: 0 errors from 0 contextsとなった。これは試した条件でエラーを観測しなかったという結果で、Boa全体にメモリの問題がないという保証ではない。

さらにaarch64 LinuxでBoa engineのlibテスト982件を含むテストとbuildを確認し、PRのCIはx86_64 Linux、aarch64 Linux、aarch64 macOSで通過した。Gate4 compatibilityは初回に加えて専用の再実行でも成功し、依存するjit-stressも通っている。Issue #858の最後の確認とPR #1187の検証一覧に条件を残した。

最初のMutation最適化は、この参照保持漏れを表に出すtriggerになった。JITも最初の失敗条件には含まれていた。だが、どちらかを無効にすることは本質的な修正ではなかった。getterの返値を受け取った後、VMレジスタというrootされる場所へ渡す前に、割り当てを伴うcache更新を置いていた。その短い区間を明示的なrootで覆うのが今回の修正だ。