自作ブラウザのOmoikaneでCSSの実装を進めていたところ、Acid3がメモリ異常で落ちるようになりました。

問題が出たのはbaseline JITを有効にした場合だけです。interpreterでは通ります。

さらに、OmoikaneのAcid3 runnerにはFaithfulとDirectDriveという2つの実行方法がありますが、Faithfulは100点まで完了し、そのあとのDirectDriveで落ちていました。

環境によって壊れ方も違います。

環境発生した異常
x86_64 LinuxSIGSEGV
ARM64 LinuxSIGABRT、約60 GBのallocation、再実行ではSIGSEGV
ARM64 macOSSIGSEGV、再実行ではSIGBUS

同じテストなのにsignalが揃っていません。

CSSの変更で見つかった問題ですが、結果的にはCSSではなく、Boaのshared shapeが持つ弱参照キャッシュの寿命が原因でした。

Issue #755PR #757の話です。

FaithfulとDirectDrive

先に、OmoikaneのAcid3 runnerにある2つの実行方法について説明します。

どちらも同じAcid3のページを読み込み、同じ100個のsubtestを実行します。違うのは、Acid3のテストループをどう進めるかです。

Faithfulでは、ページ内のinline event handlerを接続して実際のloadイベントを発火します。

Acid3の<body onload="update()">がOmoikaneのevent処理を通って実行され、その後は仮想時計を進めながら、setTimeout(update, 10)で予約された処理を順番に動かします。

load event
  -> <body onload="update()">
  -> setTimeout(update, 10)
  -> 仮想時計を進める
  -> 次のupdate()

実際のブラウザでページを開いたときに近い経路です。event handlerの接続、timer、callback、event loopのどこかが壊れていれば、途中で進まなくなります。

DirectDriveでは、このsetTimeoutの連鎖を使いません。

runner側からupdate()を直接呼び、subtestを一つずつ進めます。iframeやobjectの読み込みに必要な0msのtaskだけは処理しますが、Acid3自身が予約する10ms timerは迂回します。

runner
  -> update()を直接呼ぶ
  -> 必要な0ms taskを処理
  -> 次のupdate()を直接呼ぶ

これは、timerやload eventの実装が未完成でも、JavaScript engineやDOMが個々のsubtestをどこまで処理できるかを見るための基準です。

今回のテストでは、よりブラウザに近いFaithfulは100/100まで完了しました。そのあと、timerの連鎖を省略したDirectDriveでメモリ異常が発生しています。

同じテスト内容でも、update()の呼び出し方や小さい評価を繰り返す回数が変わるため、allocationとGCのタイミングも変わります。DirectDriveだけが壊れたことは、setTimeoutの処理が原因というより、別の実行順で既存のGC不具合が表面化した可能性を示していました。

CSSの変更を入れたらAcid3が壊れた

発端は、text-decoration-thicknessを実装していたIssue #698です。

この差分を入れたbranchでは、通常テストやWPTは通る一方、baseline JITのAcid3だけが失敗しました。同じbase revisionのmainでは成功しています。

これだけ見ると、新しく追加したCSS propertyの処理が何かを壊したように見えます。

ただ、メモリ破壊の場合は、問題を起こした場所と実際に落ちる場所が離れていることがあります。allocationやGCのタイミングが少し変わったことで、以前からあった不具合が表に出ただけかもしれません。

まず、変更したbootstrapのCSS検証Setから、追加した2項目だけを戻して試しました。

それでもDirectDriveでSIGSEGVになりました。

少なくとも、追加したCSS property名そのものが直接の原因という単純な話ではなさそうです。

最初に見えたのはPropertyMapの不整合

ローカルのARM64 Linuxでも同じテストを実行すると、終了コード101で失敗しました。

このときはPropertyMap::apply_insertにある整合性確認で止まりました。新しいaccessor propertyを追加したあと、あるはずのslotをlookupできていません。

呼び出しは次の経路です。

SetPropertyGetterByName
  -> validate_and_apply_property_descriptor
  -> PropertyMap::apply_insert

JavaScript objectへgetterを追加するときのproperty mapが壊れているところまでは分かりました。

ただ、assertionは壊れた状態を後から見つけただけかもしれません。CIではassertionではなくSIGSEGVやSIGBUSになっているので、もう少し前を調べる必要があります。

coreを調べる

次に、SIGSEGVで生成されたcoreを保存し、対応する実行ファイルをgdbで開きました。

こちらでは、もう少し手前で止まっていました。

SetPropertyGetterByName
  -> validate_and_apply_property_descriptor
  -> PropertyMap::plan_insert
  -> SharedShape::insert_property_transition
  -> GcEdge::root
  -> GcHeader::register_root

shared shapeの遷移キャッシュから既存の遷移先を取り出し、それをrootへ昇格しようとしたところで停止しています。

shared shapeの遷移キャッシュ

JavaScript objectは、property名や属性、slotの配置といった構造をshapeとして持っています。

例えば、同じshapeを持つobjectへ同じpropertyを追加すると、追加後のshapeも同じになります。

親shape
  + property x
      -> 子shape

この遷移を毎回作り直さずに済むよう、親shapeは「このpropertyを追加したときはこの子shapeになる」というforward transitionをキャッシュしています。

ただし、遷移先の子shapeをキャッシュが強く保持すると、もう使われていないshapeまでGCで回収できなくなります。

そこで遷移先は弱参照として保持します。

親shape
  -> 遷移キャッシュ
       -- weak --> 子shape

子shapeをほかで使っている間はキャッシュから再利用できます。使われなくなればGCで回収でき、次に同じ遷移が必要になったときは作り直します。

弱参照を管理する領域が先に消えていた

保存したcoreを見ると、古い親shapeの遷移mapにはcache entryが残っていました。

ところが、そのentryが参照しているephemeronのallocationはすでに解放され、別の用途に再利用されていました。

親shape                      GC後
  -> cache entry              -> 残っている
       -> ephemeron allocation -> 解放・再利用済み
            -- weak --> 子shape

キャッシュ自体は残っているのに、弱参照を管理するための領域が先に消えています。

その状態で遷移を再利用しようとすると、解放済みの管理領域からshape pointerを取り出すことになります。得られる値はもう信用できません。

環境によってSIGSEGV、SIGBUS、約60 GBのallocationと症状が変わっていたのも、この無効な値をどのように使ったかの違いでした。

遷移先は弱いまま、ephemeronだけ生かす

修正前の遷移mapは、次の型を保持していました。

FxHashMap<T, WeakGcEdge<SharedShapeInner>>

修正後はWeakGcを保持します。

FxHashMap<T, WeakGc<SharedShapeInner>>

WeakGcは弱参照を実装するephemeron allocationをcache entryの寿命までrootとして保持します。一方で、遷移先の子shape自体を強く保持するわけではありません。

つまり、次の2つを分けます。

  • cache entryがある間は、弱参照を管理する領域を生かす
  • 遷移先のshapeは、ほかから使われなくなれば回収してよい

単純に遷移先を強参照へ変えれば異常終了は止まるかもしれませんが、それでは不要なshapeを回収できません。

今回はweak semanticsを残したまま、管理領域の寿命だけをcache entryへ合わせました。

GCを挟んで回帰テストする

回帰テストでは、同じ親shapeからpropertyを追加し、major GCとminor GCを何度も挟んで遷移キャッシュを再利用します。

子shapeを保持している間は、キャッシュから同じshapeを取得できる必要があります。

子shapeを保持
  -> major/minor GC
  -> 同じ遷移を取得
  -> 同じ子shapeを再利用

そのあと子shapeをdropしてmajor GCを実行すると、弱参照はupgradeできなくなります。再び同じpropertyを追加したときは、新しい子shapeが安全に作られる必要があります。

子shapeをdrop
  -> major GC
  -> weak参照はupgrade不可
  -> 同じ遷移を安全に再生成

「異常終了しない」だけではなく、弱参照にしていた本来の目的も維持していることを確認しました。

修正後の確認

ローカルでは、次のテストが通りました。

  • shared shapeの回帰テスト 4件
  • Boa engine unit test 968件
  • baseline JIT回帰テスト 79件
  • ARM64 Linuxのbaseline JIT Acid3でFaithful、DirectDriveともに100/100

PRのCIでは、x86_64 Linux、ARM64 Linux、ARM64 macOSについて、interpreterとbaseline JITのブラウザテストがすべて成功しました。

Acid3、JIT stress、Test262、通常テスト、WPT、各環境のGate 5も通っています。

まとめ

今回はCSS propertyの実装branchで、baseline JITのAcid3が環境ごとに違うメモリ異常を起こしました。

最初はCSS変更との関係を疑いましたが、変更の一部を戻しても再現しました。coreを追うと、shared shapeの遷移キャッシュが、すでに解放されたephemeron allocationを参照していました。

修正は、遷移先を強く保持することではなく、弱参照を管理するallocationだけをcache entryの寿命まで保持するものです。遷移先のshapeは、不要になればこれまで通り回収できます。

メモリ異常は、落ちた場所だけを見ても原因が分かりませんでした。CSSの差分も直接の原因ではなく、allocationやGCの条件を変えて既存の問題を表に出したtriggerだったようです。

signalも環境ごとに違いましたが、coreで無効な参照へ至る経路を確認すると、同じ原因として整理できました。