Omoikaneに、before()やreplaceChildren()といったDOM操作と、window.onerror、未処理Promise rejectionの通知を追加した。PR #1322の話である。最初の入口は、ページが普通に呼ぶAPIが足りないという、分かりやすい問題だった。ところが実装と検証を進めると、HTMLテキスト挿入の兄弟一覧コピー、UTF-16追記の全文再構築、不要なiframe Realm生成、PromiseのGC root不足まで直すことになった。

名前の付いたAPIを増やすだけなら、記事ももう少し短くできる。しかし今回の差分は、APIが返す値だけでなく、その途中で走るユーザーコード、イベントの届く時刻、退役した文書の所有者、GCが見える参照までつながっている。replaceChildren()が新しい木を作る瞬間と、unhandledrejectionのコールバックがGCを起こす瞬間は、同じブラウザの同じオブジェクト群を別の方向から触る。そこを省いて「対応しました」と書いても、何を守って実装したのかが伝わらない。

今回はかなり長い記録にした。それぞれの機能について、何が足りなかったのか、入力をどの順番で処理するのか、どの観測結果をテストで固定したのかを説明する。性能の節では、処理時間の実測と、コピー量から分かることも分ける。個別の修正に興味があれば、次の入口から読める。

この記事で説明する実装の時点

説明の基準は、2026年10月10日にマージした994f17ccである。執筆時に確認したmainもこのコミットだった。PRの最終headは7ad562bf、比較元はe69ee73cで、APIに表示しきれなかった大きなファイルについても、両時点のソースを照合した。記事中のコードとテストのリンクは、基本的にマージ時点のコミットへ固定している。

計測値は、PR本文と調査経緯の最終コメント、関連Issueに保存した報告に基づく。この記事のためにブラウザの全テストや性能比較をもう一度実行した結果ではない。ソースから説明できる処理、報告された実測、まだ検証していない改善案を混ぜないようにする。

変更前に足りなかったDOM APIはIssue #1284、ページ内のエラー通知はIssue #1289で整理した。後者はCDPのRuntime.exceptionThrownとは別の経路である。外からブラウザを操作するクライアントが例外を知ることと、ページ自身のaddEventListener('error', ...)へ通知が来ることは同じではない。ページ内の監視処理が動くためには、ブラウザがそのページのglobalへイベントを届ける必要がある。

また、ここでいうBoaはOmoikaneのengine/boa/へ組み込んだ実装を指す。Omoikaneで加えた修正を、そのまま上流Boaの任意のリリースの状態として説明するつもりはない。以前のgetter返値のroot不足やephemeronと世代別GCの不具合とつながるところはあるが、今回のPromise.finallyの問題は、別の値、別の区間の保持漏れである。

APIの追加が下の層へ広がった理由

先に用語を揃えておく。Realmは、global objectやArray、Dateなどの組み込みオブジェクトを持つJavaScriptの実行環境の単位である。iframeの内外ではRealmが分かれ、同じ名前のconstructorでも別のオブジェクトになる。originは、その間でどの情報へアクセスできるかを判断する別の境界だ。今回のRealmの型には、この環境とhost側の所有情報を結び付ける入口がある。

wrapperは、Rust側のnative nodeをJavaScriptへ見せるオブジェクトを指す。identityを保つというのは、同じ内容の別objectを作るのでなく、===で同じものとして比較できるようにすることだ。GCのrootは、生存している値を探す起点である。Rustのローカルにhandleが残ることと、そのhandleがrootとして登録されていることは別なので、後半ではどの区間でrootを持つかを細かく見る。

ブラウザのDOMは、Rustの木構造だけで完結しない。ページのJavaScriptから見えるwrapper、native nodeのidentity、所有Document、Realm、イベントリスナー、Range、MutationObserver、custom elementのreactionが重なっている。木の中で同じnodeを動かしても、removeして別nodeを作った場合と同じ観測にはならない。逆に、同じwrapperが残っていても、その背後の所有Documentを新しいactive Documentへ勝手に付け替えてよいわけではない。

例外通知にも同じ事情がある。Rust側でエラーを受け取った時点で、元のscriptのRealmやsource位置がすでに復元されていることがある。そこで単に「現在の文書へエラーイベントを投げる」と、例外の発生元、cross-originのmuted errors、Workerのownerがずれる。Promise rejectionでは、rejectした瞬間と、未処理だと確定するcheckpointも違う。その間にhandlerが付けば、通知してはいけない場合がある。

性能修正は、その意味を保ったまま余分な仕事を消す変更になった。末尾の一件を得るために全childrenをcloneしなくてよい。履歴URLを読むためだけに子Realmを初期化しなくてよい。処理済みmoduleのASTを、評価可能な状態と一緒にいつまでも持たなくてよい。ただし、どれも本当に不要な保持や生成なのかを確かめる必要がある。コピーをなくした結果、GCから必要な値が消えたり、author getterを呼ぶ順番が変わったりすれば、別の不具合を作る。

以下では、まずページから見えるDOMとHTMLの機能を説明し、次に通知、GCとiframe、性能と検証の順に進む。途中に置く短いコードは、特に断らない限り、挙動を説明するための例である。実際に計測に使ったfixtureや回帰テストと同一だと扱う場合は、そのテストへ直接リンクする。

DOMの便利なメソッドにも、挿入の規則を全部通す

DOM側の出発点はIssue #1284だった。10月7日の確認ではChildNode・ParentNodeの操作、moveBefore、adoptNode、shadow treeを含むparse・serialize APIが足りなかった。名前を増やすだけなら簡単そうだが、文字列変換、例外、MutationObserver、live Range、custom element、iframeの寿命まで一緒に動く。 Issue #1284

まず公開先を整理した。prepend()、replaceChildren()、moveBefore()などのParentNodeのメソッドはElement、Document、DocumentFragmentへ置く。before()、after()、replaceWith()というChildNode側のメソッドはElement、CharacterData、DocumentTypeへ置く。全部を基底のNodeへ足すと、存在しないはずの場所でも呼べてしまう。今回のプロトタイプへの配置は、この区分をそのまま表している。

Nodeか文字列かを、木を動かす前に決める

before()などは、Nodeだけでなく文字列も複数受け取れる。次のコードは説明用の例だ。

const parent = document.createElement('div');
const marker = document.createElement('span');
parent.append(marker);
marker.before('head', null, undefined);

ここでは三つのTextノードがmarkerの前にできる。内容は順にhead、null、undefinedになる。nullを「何も挿入しない」と扱うAPIではない。一方、すでにNodeである値は新しいNodeへ変換せず、同じオブジェクトを移動する。marker.before(existing)の前後で、existingを変数に保持していたコードは同じNodeを指し続ける。

convertMutationArgumentsは、引数を正規のNodeとして識別できればそのまま残し、それ以外をDOMStringへ変換する。ここで公開のString()をそのまま使うだけだと、Symbolを文字列にできてしまう。DOMStringへの変換ではSymbolにTypeErrorを投げるため、toDOMStringでその条件を明示している。

変換のタイミングも意味がある。引数のオブジェクトにはtoString()があり、その中で任意のJavaScriptを実行できる。一つ目の引数Nodeを先に元の親から外し、二つ目の文字列変換が失敗すると、呼び出しは例外になったのに最初のNodeだけ動いた状態になる。今回の実装は、文字列への変換を先に一通り終えてから、Nodeを組み立てる処理へ進む。

親を持たない要素のbefore、after、replaceWithでも、挿入しないだけでDOMString変換は行う。detachedのテストはtoStringが一回呼ばれ、SymbolでTypeErrorになることを三つのAPIで確認する。 detachedのテスト

変換済みの引数をNodeへまとめるのはmutationArgumentsNodeだ。文字列を呼び出し先のDocumentでTextへ変換し、一つだけならそのNodeを返す。複数ならDocumentFragmentを作って順に入れる。ここで同じNodeを二度渡しても、同じNodeが二個の兄弟になれるわけではない。後の挿入で前の位置から動くため、最後の位置に一つだけ残る。

この点を兄弟操作のテストはa.replaceWith(a, 'replacement', a)で確認している。同じaをコピーして増やすのではなく、replacementのTextと、元のaのidentityを持つNodeが並ぶ。HTML文字列に直してからparseし直す実装では、このidentityは保てない。

beforeとafterは、参照兄弟を途中で失う

before()は自分の直前、after()は自分の直後へ挿入する。説明だけなら一行で済むが、引数に自分や隣の兄弟が入ると、その「直前」「直後」が動く。説明用に、親の子がa、b、cの順に並ぶ木を考える。

b.before(a, b, 'x');

複数引数をFragmentへまとめる間に、aもbも元の親から外れる。元のbを参照Nodeにしたまま最後にinsertBeforeすれば、もう親の子でなくなっているので失敗する。呼び出しが指定した位置を、引数をまとめる前の木から決めておく必要がある。

mutateSiblingは、変換済み引数に含まれるNodeのidを集める。beforeなら直前の兄弟から前へ、afterやreplaceWithなら直後の兄弟から後ろへ進み、引数に含まれない兄弟を探す。その兄弟は引数のFragment化で動かないので、挿入位置の目印として使える。

beforeの場合は、残る直前の兄弟の次を挿入先とする。残る兄弟がなければ親の先頭に入れる。afterの場合は、残る直後の兄弟をそのまま参照先とする。参照Node自身が挿入するNodeなら、その次の兄弟へずらす。これがないと、自分の前へ自分を入れる処理の扱いが崩れる。

viabilityの回帰テストは、a、b、cに対してb.before(a,b,'x')を行い、a、b、x、cの順を確認する。その後b.after(c,b,'y')を行い、動く引数をまたいだ位置を確認する。b.replaceWith()という引数なしの呼び出しではbを取り除く。このテストは、文字列一つを前後へ入れられるという基本ケースより、参照位置の保存を狙っている。

replaceWith()は、引数をFragmentへ変える間にreceiver自身がすでに元の親から移ったかどうかも見る。まだ同じ親にいれば置き換える。すでに外れていれば、残した参照兄弟の前へ引数Nodeを挿入する。名前にreplaceが含まれるからといって、必ずreceiverをもう一度削除するわけではない。

prependとreplaceChildrenは、Documentにも置く

prependはElement、DocumentFragment、Documentの先頭へ挿入する。ただしDocument直下にはTextを置けず、Elementは一つだけで、DOCTYPEはElementより前という制約がある。

ensureAppendValidityは、親が子を持てる種類か、挿入Nodeが許される種類か、参照Nodeが本当に親の子か、循環を作らないかを調べる。Fragmentなら中にある子を全部取り出して、挿入前に一通り検証する。Fragmentの先頭のCommentだけを挿入した後、続くElementで失敗するという部分的な変更を避けるためだ。

Documentの場合は、現在の子をそのまま数えるだけでは足りない。Document内の既存Elementを移動する操作では、挿入前の木にそのElementがすでにある。replaceChildren()では、今あるElementをすべて置き換え対象から除く。実装は挿入する子と置き換える子を現在の一覧から除き、残る子へ新しい子を挟んだ「結果の並び」を作って調べる。

結果の並びにElementやDOCTYPEが二つあれば失敗する。Elementの後のDOCTYPEも失敗する。CommentとProcessingInstructionは許される。挿入前の数でなく、操作後の並びを検証する。

Document操作のテストでは、DOCTYPEのあるDocumentへ既存rootをprependして、ElementがDOCTYPEの前に来るためHierarchyRequestErrorになることを確認する。失敗後もrootとDOCTYPEの位置は元のままだ。続いて別Documentで作ったhtml要素をreplaceChildrenし、その要素を新しいrootにする。ownerDocumentも置き換え先へ変わる。

replaceChildren('text')はDocument直下へTextを作ろうとして失敗する。その後も直前のrootが残る。例外の型だけ合えばよいというテストではなく、失敗で既存状態を壊さないことまで見ている。

ElementでのreplaceChildren(parent)も、親を自分自身の子にするので拒否する。置き換えの回帰は、もとのspanが残り、MutationRecordも一件も出ないことを確かめる。正常な置き換えでは新しいbと末尾のTextを入れ、追加と削除を一件のchildList recordにまとめる。

木を変える操作と、変更を知らせる操作を分ける

DOMのnativeな木を正しく更新しても、ページが観測する記録が違えば互換性は崩れる。replaceChildren()で十個の子を消して十個入れたとして、二十件の記録を並べればよいわけではない。呼び出しが表す置き換えとして、removedNodesとaddedNodesをまとめた記録が必要になる。

replaceChildrenの実装は、削除対象と追加対象を先に保存する。その後、内部の削除と挿入では対象親へのobserver通知を抑え、最後にまとめたchildList recordをqueueする。削除はなかった、追加もなかったという空操作では、無意味な記録を出さない。

ただし、observerを抑えるのは「その置き換えの記録を一つにまとめるため」の範囲だ。Nodeが別の親から来たなら、元の親から取り除かれることも観測対象になる。replaceWith()のコードにも、単独のreplacementを元の親から外す処理は独立して観測できる、という扱いがある。全通知をまとめて消してしまうと、別の木を観測しているobserverの記録まで失う。

DocumentFragmentも、それ自身の子が抜ける記録と、挿入先へ入る記録は別になる。Fragment挿入のテストは、appendChild、insertBefore、prepend、replaceChildrenの四経路で同じ契約を確認する。Fragmentのaとbが抜けた一件、その後親にaとbが入った一件で、合計二件だ。aが抜けた、bが抜けたという二件に分割しない。

live Rangeは、削除で境界が動く

Range更新のテストは、二つのTextを持つ親をselectNodeContentsで選び、replaceChildrenで新しいTextへ置き換える。Rangeの両端は親のoffset 0へ移り、collapsedになる。引数なしのreplaceChildrenで新しい子も取り除けることを確認する。

内部では削除前のpreRemoveが、NodeIteratorとRangeに木の変化を知らせる。removed nodeの位置を記録した上で境界を直すため、native側の削除を済ませてから呼ぶと、必要な旧位置を失う。removeChildInternalは、この更新、nativeの木の変更、接続状態の変更、custom elementの反応、MutationRecord、slotの更新を順に実行する。挿入と削除の共通経路にまとめた理由は、新しい便利APIでもこの一連の処理を外さないためだ。

custom elementの反応が途中の木を見ないようにする

replaceChildren()はreaction用の配列を作り、個々の削除・挿入からその配列を渡す。全体の置き換えと記録の生成を済ませ、挿入したcustom elementのupgradeを準備し、実行可能なscriptを処理した後、finallyでreactionを流す。この順序はHTMLをparseして一括で入れる新しいAPIにも使われる。

共通経路に集めることには、別の利点もある。例えばreplaceChild()を実装するのにページ側のthis.removeChild()とthis.insertBefore()を呼ぶと、そのメソッドを上書きされた場合に内部操作まで止まる。従来APIの回帰テストは、Documentの公開メソッドを例外を投げる関数に置き換えた上でも、replaceChild()がnativeの操作でrootを置き換えられることを確認する。

従来のappendChildも、doctypeのElementへの挿入、偽の__idを持つオブジェクト、途中で不正になるFragmentを検証する。正規NodeのnodeTypeやisConnectedを例外getterで上書きしても、内部判定はnative状態を使う。 native brandと一括検証のテスト

pre-insertの検査を減らすときも、検査済みという条件を残す

循環の確認は、挿入先から親を上へたどり、挿入Nodeと同じものがないかを見る。shadow rootを越えたhostも含むため、単なるparentNodeの連鎖だけでは足りない。この祖先走査を入口と内部で何度も繰り返していた。

ensureAppendValidityでは、非Fragmentの候補はそのNode一つである。そこでFragment自身とその子をまとめて調べる形を非Fragmentにも使うと、同じNodeを二回確認していた。変更後はFragmentだけをFragment自身と子の列にし、非Fragmentは一つだけにする。確認すべき候補を減らしたのではなく、候補の重複を消した。

さらにappendChildとinsertBeforeが完全なpre-insert検証を済ませた経路では、insertNodeBeforeInternalへvalidityCheckedを渡せるようにした。これで内部の循環確認を重ねずに済む。ただし、このflagのない内部呼び出しでは従来のguardを残している。内部関数だから安全だろう、とすべての呼び出しから検査を外す変更ではない。

moveBeforeは、通常の取り外しと挿入の省略形ではない

既存NodeをappendChild()で別の親へ入れると、元の親から外して新しい親へ入れる。一方、moveBefore()は状態を保つ移動を行うAPIだ。見た目の木の変更は似ていても、接続の切断に伴う処理が違う。

iframeの状態保持テストはcontentWindowとcontentDocumentを保存し、childのglobalにmoveMarker={value:42}、bodyにmarker=43を置く。移動後もwrapperが同じで、child Realmのevalから両方を読めることを確かめる。 iframeの状態保持のテスト

状態を保てない移動は、その場で拒否する

moveBeforeの検証は、正規のNodeとnullableな参照Nodeを受け取り、必要な二引数があるかを確認する。nullを参照に渡せば末尾へ動かす。参照が移動Node自身ならその次の兄弟に直す。

次に、hostを含む親の連鎖をたどり、移動先と移動Nodeが同じrootにいるかを調べる。rootが違えばHierarchyRequestErrorだ。同じownerDocumentを持っていても、親を持たずに切り離されているNodeは、接続された木と同じrootにはならない。このAPIを新規Nodeの挿入に使うことはできない。

移動先が移動Nodeの子孫なら循環するので拒否する。参照Nodeが移動先の子でなければNotFoundErrorになる。移動対象として許す種類もElement、Text、CDATA、ProcessingInstruction、Commentに絞る。DocumentやDocumentFragmentは同じように移動できない。Document直下にTextが来る条件や、root要素とdoctypeの順序が不正になる条件も調べる。

rootと参照のテストは、connectedな親へdetachedなspanを移す操作を拒否する。通常のappendでspanをその木へ入れた後、親の子でないdocument.bodyを参照に渡し、NotFoundErrorを確認する。最後にspan自身を参照にして、唯一の子の位置が壊れないことを確認する。

接続callbackと移動callbackの違い

custom elementには接続・切断のcallbackに加え、connectedMoveCallbackを扱う経路がある。move先が接続された木ならcustom elementを走査し、このcallbackを持つ定義には移動を知らせる。持たない定義にはdisconnectedとconnectedのcallbackを順に呼ぶ。

custom elementのテストは、三つのcallbackがそれぞれログを足す定義を使う。最初の接続ログを消してからaからbへ移すと、ログはmovedだけになる。同時にMutationRecordは二件で、元の親aからの削除、新しい親bへの追加という順に出る。

状態を保つ移動でもRangeの境界は調整する

updateTraversalForMoveは古い親とindexを保存する。NodeIteratorとRangeへ削除前処理を行い、その後、参照Nodeのindexを取得する。同じ親の中で前から後ろへ動かす場合は、元の位置が抜ける分だけindexを調整する。挿入先親を境界に持つRangeでは、挿入位置より後ろのoffsetを一つ増やす。

Rangeの移動テストは、aの子spanのTextを選ぶRangeと、bの子の末尾を指すcollapsed Rangeを作る。spanをbの二つ目の子の前へ動かすと、span内を選んでいたRangeは元の親aの境界へ移り、bの末尾offsetは2から3へ変わる。状態保持という言葉の対象を広げすぎないために、この観測結果も具体的に押さえたい。

Selectionに関連付けたRangeは、同じオブジェクトを保持したまま更新する。選択を伴う移動の回帰は、入力経路でspanのselectedを選び、そのRangeを保存してからspanを別の親へ動かす。getRangeAt(0)が保存したRangeと同じで、境界が元の親のoffset 1へcollapsedになることを確かめる。

focusの補正は、移動直後の状態だけでは決めない

focusされたbuttonをhiddenな親へ動かすと、focus可能な状態でなくなることがある。ただ、移動の途中で即座にblurさせると、同じ処理の続きでbuttonを見える場所へ戻しても、不要なblurとfocusoutを出してしまう。

moveはnativeの移動後にqueueMovedFocusFixupを呼び、補正を後へ送る。呼び出し直後にはactiveElementがbuttonのままで、blurもfocusoutもまだ出ない。後から実行する補正は、その時点の状態を使う。

focus補正のテストは三つの条件を比べる。hiddenへ動かしたままなら、event loopを回した後でactiveElementはbodyになり、blurとfocusoutが出る。補正前に見える親へ戻せばbuttonがfocusを保ち、イベントは出ない。別のbuttonへfocusを移せば、その新しいfocusを古い補正が奪わない。

adoptNodeは、コピーせずに所有Documentを変える

importNode()はコピーを作る。adoptNode()は渡されたNodeそのもののownerDocumentを変える。説明用には次のように使える。

const target = document.implementation.createHTMLDocument('target');
const saved = element;
const adopted = target.adoptNode(element);

adoptedはsavedと同じNodeで、古い親からは外れる。targetのbodyへ自動で入るわけではない。所有者の変更、親子関係の変更、コピーの生成はそれぞれ違う操作だ。

入口の検証はreceiverが正規Documentであることを確認する。引数はcanonicalなnative Nodeか、Attrのprivate stateを持つ値でなければTypeErrorだ。Document自身はNotSupportedError、shadow root自身を独立にadoptする操作はHierarchyRequestErrorになる。

shadow root自体のadoptionは拒否するが、hostをadoptする時はshadow treeも所有Documentを変える。子孫と属性、closed rootの子も対象になる。

identityとownerのテストは、div、Textの子、data-xのAttr、closed rootとそのTextを保存する。divをadoptした後、返値が元のdivで、firstChildも元のTextで、全員のownerDocumentがtargetであることを確認する。closed rootも対象なので、公開のshadowRoot getterで見える範囲だけを走査する実装では通らない。

古い親からの削除は通常の削除経路を通る。前の親のobserverには一件の削除recordが出る。内部Textを選んでいたRangeは、古い親の削除位置へ移る。同じテストがこの二つも確かめる。所有者のidだけを書き換えて終わる操作ではない。

adoptedCallbackとtemplate contentの所有者

reactionのテストではoldDocが元のdocument、newDocがtargetならadoptedを記録する。接続時のログを消してからadoptすると、ログはdisconnected,adoptedになる。同じtargetへもう一度adoptしてもDocumentは変わらないので、reactionは増えない。

templateでは、要素のownerDocumentとinertなcontentのownerDocumentを区別する。templateのテストは、contentのFragmentそのものをtargetへadoptし、その子もtargetをownerに持つことを確認する。一方、template要素は元のdocumentに属したままで、template.contentは保存した同じFragmentを返す。

getHTMLは、出力するshadow treeを選ぶ

innerHTMLでhostの内容を文字列にしても、shadow treeは普通のchildNodesに含まれない。getHTML()は通常の子の出力に加え、選んだshadow rootを宣言的shadow DOMのtemplateとして出す。

説明用に、hostのlight treeにTextがあり、closed rootの中にbがある場合を考える。

host.getHTML();
host.getHTML({ serializableShadowRoots: true });
host.getHTML({ shadowRoots: [savedClosedRoot] });

一つ目はlight treeだけを返す。二つ目はserializableなrootを含める。三つ目は呼び出し側が持つrootを明示選択する。closedだから明示選択を拒否するのでも、closed rootを無条件に全部出すのでもない。

選択のテストはclosed、serializable、clonable、delegatesFocusを持つrootを使う。通常のgetHTMLがlightだけを返し、選択した場合はtemplateにshadowrootmode、shadowrootdelegatesfocus、shadowrootserializable、shadowrootclonableが出る。root自身のgetHTMLはその中身だけを返す。

rootの設定を保持し、選んだ木だけをたどる

native側のOptionsはserializableのbooleanと、明示選択したrootのidentity集合を持つ。Elementの中身を出す時、rootのsettingsを読んで条件を満たすrootだけをtemplateにする。入れ子のrootも同じOptionsで処理する。

選択rootの一覧から独立したHTML片を並べるのではなく、実際のhostの木にshadow treeを差し込む処理だ。nestedのテストは、外側のopen rootを明示選択し、その内側のclosed・serializableなrootをbooleanで選択した出力を確認する。

settingsはJSの公開プロパティから都度作らず、native DOMで保持する。parseした宣言的rootもattachShadowで作ったrootも同じ情報を持つ。宣言的フラグのテストは、HTMLから読んだclosed、serializable、clonableが出力へ戻ることを確認する。

iterableの変換もAPIの挙動に含まれる

shadowRootsは配列だけでなくiterableなsequenceを受け取る。Symbol.iteratorを読むとgetterが実行され、その回数をページから観測できる。変換処理はiterator methodを一回だけ取り、保存した関数を元のreceiverで呼ぶ。要素がShadowRootでなくて変換に失敗した時もIteratorCloseを行う。

sequenceのテストはiterator getterを二回読んだら例外を投げるオブジェクトを使う。optionsの読まれる順がserializable、rootsであることも確認する。次に不正なdocument.bodyを返すiteratorを渡し、TypeErrorになった際にreturnが一回呼ばれることを確かめる。

HTML要素かどうかと、raw textかどうかを別に見る

Textの&、<、>、nbspは通常escapeするが、scriptやstyleのraw textはそのまま出す。HTMLのbrなどvoid要素は子や終了タグを出さない。名前だけで判断すると、namespaceなしのXMLのscriptやbrまでHTMLとして扱ってしまう。

HTML判定はnamespaceを見る。namespaceを省略した旧native HTML constructorだけはHTML brandも使う。比較テストではXMLのbrはcontentと終了タグを出し、XMLのscriptはescapeする。HTMLのbrは子を省き、scriptはraw textになる。 HTML判定 比較のテスト

独自namespaceのp:itemはprefix付きのqualified nameを保つ。属性はnamespaceに従ってxml、xmlns、xlinkの表記を使う。名前のテストは、XML namespaceでalternate:langを作っても、出力がxml:langになることを確かめる。属性値の&はescapeする。

noscriptはownerDocumentでscriptingが有効かによってraw textの扱いが変わる。ownerごとのテストはactiveなDocument、inertなDocument、scriptsを許さないsandboxの三つで同じTextを入れる。active側はそのまま、inertとsandbox側はescapeする。

template内のnoscriptはcontentのinertなownerを使う。templateのテストはそのescapeを確認する。serializerは各contextのownerを解決するので、adoptionでの所有者の変更も出力へ反映される。 templateのテスト

setHTMLUnsafeは、宣言的shadow DOMをparseする入口を分ける

getHTMLでshadow treeをtemplateとして出したら、その文字列をもう一度DOMへ戻したくなる。ただし、従来のinnerHTMLへ代入する経路では、宣言的shadow DOMのtemplateを普通のtemplateとして扱う。今回追加したsetHTMLUnsafeとparseHTMLUnsafeは、shadow rootを作るparseを明示的に選ぶ入口だ。

説明用に、divへ次の文字列を渡す場合を考える。

host.setHTMLUnsafe(
  '<template shadowrootmode="open"><span>inside</span></template>outside'
);

shadow rootにはspanが入り、light treeにはoutsideというTextが入る。hostのfirstChildとしてtemplateが残る形とは違う。直接hostへparseするテストは、この二つを区別する。ShadowRoot自身へsetHTMLUnsafeした場合も、その中のsectionへ宣言的rootを作れる。

既存rootがあるhostへ、別modeの宣言的templateを入れた場合は、既存rootを勝手に作り替えない。同じテストでは、最初に作ったopen rootが同じrootとして残り、後からclosedを指定したtemplateは通常のlight側templateになる。rootが一つしか付かないというDOM側の条件を、parseでも守る。

template自身へのsetterはchildNodesでなくcontentを置き換える。同じテストはtemplate.childNodesが空のまま、contentにpができることを確認する。挿入先の木とownerを区別する必要がある。

parseした木をfilterしてから、登録と置き換えへ進む

setterの共通処理は、receiverを検証し、HTMLをDOMStringへ変換し、optionsを読む。その後nativeのfragment parseへ渡す。返ってきたFragmentと、新しく付いたshadow rootのownerをstampしてから、replaceChildrenで対象の中身を置き換える。

Rust側のfragment parseは、JS文字列をUTF-16の列で受け取る。ShadowRootがreceiverならhostをparsing contextに使い、TreeBuilderへshadow rootを許すfragment parseを依頼する。sanitizerがあれば、新しく作ったFragmentとcontextの新しいrootをfilterしてから、DOMの登録やresourceの準備へ進む。

fragmentのcontextには意味がある。scriptへif (a < b) {}を渡すとTextになる。tableではfoster parentingのTextが人工contextの外へ出る。TreeBuilderはcontextの子だけを取らず、人工rootでcontextを展開し、前後の兄弟もFragmentへ戻す。 TreeBuilderのfragment処理

既存innerHTMLの動作も変えない。legacyとの比較は、innerHTMLで宣言的templateを入れたsectionにはshadow rootができず、普通のtemplateが残ることを確認する。新しいAPIの追加に合わせて、古いAPIまで暗黙に別のparseへ切り替える変更ではない。

XML DocumentへのsetHTMLUnsafeもHTML parserを使う。子のpはHTML namespaceで、ownerは元のXML Documentだ。MIMEは変わらない。三種のXML MIMEを使う回帰では、壊れたbとiの入れ子がHTMLとして修復され、XMLのinnerHTMLではxmlns付きになる。 XML側のテスト

parseHTMLUnsafeのDocumentは、元のwindowを持たない

Document.parseHTMLUnsafeは独立したDocumentを作る。URLはabout:blank、defaultViewとlocationはnull、contentTypeはtext/htmlで、rootや宣言的shadow treeの子は新しいDocumentをownerに持つ。

inert Documentのテストは、元のdocumentと異なること、windowを持たないこと、scriptが実行されないことを確認する。さらにparseでできたscriptをactive documentへ移しても、このテストでは実行されない。Unsafeという名前を見て、parseした瞬間にscriptを動かすAPIだと読むのは間違いだ。

document_nativeはinertなUTF-16 parseを行い、必要ならfilterし、独立Documentのidentityで木とstyle状態を登録する。scriptingを無効にしたparseなのでnoscriptの中は普通の要素として読める。parse modeのテストは、noscriptの中の二つのpがそれぞれできること、DOCTYPEなしならBackCompatであること、baseURIの初期値がabout:blankであることを確認する。

scriptを実行する条件と、実行順序をテストする

このrevisionのsetHTMLUnsafeはrunScriptsを明示した場合だけ実行を準備する。既定ではscriptはinertだ。Document.parseHTMLUnsafeはscript実行flagを使わない。このoptionsの存在から他のブラウザの対応状況までは言えない。

実行順序の回帰は、optionsのgetter、script、custom elementのconstructor、connectedCallbackの順をログで見る。scriptより後のdivもDOMへ入った後でscriptが走るので、scriptからそのdivを取得できる。document.currentScriptは挿入されたscriptを示す。結果のログはoption、script、constructor、connectedになる。

shadow側のscriptのテストは、入れ子の宣言的rootにも実行対象が届くことを確認する。同時に、先行scriptが後続scriptをremoveしたら、その後続は実行しない。実行済みscriptを外して入れ直しても、もう一度実行しない。対象一覧を作ったからといって、実行直前の接続や実行済み状態の確認を省けない。

外部scriptの回帰は、既定のinert、明示実行、一度だけの実行、load通知を確認する。nestedなcurrentScriptの復元やshadow内の扱いも別に検証する。 別のテスト

Sanitizerは、parseした木を接続前にfilterする

Unsafeな入口へsanitizerを渡せることと、安全な入口としてsetHTML・parseHTMLを提供することも、このPRの範囲に入っている。単純に文字列からscriptタグだけを削る処理ではない。HTML parserで木にして、名前空間と属性のrecordを見て、template contentとclosed shadow treeも走査する。

例えば説明用に、scriptを除き、secret属性を除き、javascript URLを許さない設定を渡せる。

host.setHTMLUnsafe(markup, {
  sanitizer: {
    removeElements: ['script'],
    removeAttributes: ['secret'],
    javascriptURLs: false
  }
});

設定を省略したsetHTMLUnsafeは、このfilterを自動で全部行うAPIではない。一方、setHTMLとDocument.parseHTMLはsafe側として設定を処理し、effectiveな設定からunsafeな要素や属性を除く。safeのscript contextへのsetterは、元のscript内容を変えずに戻る経路を持つ。

設定を読み終え、検証してから木を変える

JS側のbridgeは、authorのdictionaryやsequenceを読み、ownedな設定データに変換する。Rust側はそのJSONをConfigへdecodeし、canonicalizeとvalidateを通す。filter中にauthorのgetterを呼ばず、active Documentのborrowを持ちながらJSへ戻らない構造にしている。

configurationの検証は、allow listとremove listの同時指定、重複した要素、重複属性、矛盾したlocal属性の設定などを拒否する。replaceWithChildrenの対象としてhtml、svg、mathを指定することも許さない。globalで許可した属性をlocalで重ねて許可するような、冗長で不正な設定も検証する。

無効設定のテストは六種類の不正設定を渡す。各呼び出しはTypeErrorで失敗するが、元のb要素はそのままで、新しい宣言的shadow rootも付かない。HTMLを先にparseしてhostへrootを付け、その後に設定のエラーを見つける順序では、この条件を満たせない。

設定に使ったオブジェクトをそのまま保持するのも避ける。new Sanitizerへ渡したinputの要素名を後でscriptへ変えたり、getが返したsnapshotを書き換えたりしても、内部設定は変わらない。owned stateのテストはこの両方を確認する。APIはcallerの可変オブジェクトへの参照を、filterの正当性に使っていない。

Sanitizerには設定を変更するメソッドもある。allowElementとremoveElementはallow方式・remove方式に応じて該当するlistを更新し、replaceElementWithChildrenは外側の要素だけを外す方針へ変更する。allowAttributeとremoveAttributeはglobalとlocalの矛盾や冗長な指定を整理する。ProcessingInstructionのallow・removeはtargetを使う。setComments、setDataAttributes、setJavascriptURLsは各booleanを変更し、変更があったかを返す。modifierの実装は、同じ値の再指定ならfalseを返す。

owned stateの回帰は、bを子だけ残す方針へ変更し、iのclassを除いてtitleは保つ。htmlへの同じ変更はfalseで拒否する。removeUnsafeはunsafe要素、event属性、javascript URLの設定を更新し、二度目は変更なしになる。safe parseは設定のcloneへremoveUnsafeを適用するため、共有したSanitizerの内部設定を勝手に書き換えない。

公開のgetを呼ばず、privateな設定を使う

Sanitizerインスタンスの設定はsharedなWeakMapへ保存する。別Realmから渡された正規Sanitizerも、そのprivateなbrandで識別する。filterへ渡すために公開のsanitizer.get()を呼ぶと、そのメソッドをページが上書きできる。設定を取得する内部処理は、公開のgetを経由しない。

conversionの回帰は、scriptをremoveするSanitizerのgetを、例外を投げる関数へ置き換える。それでもrunScriptsをtrueにした挿入でscriptは削除され、bだけ残る。公開メソッドのoverrideが内部設定の取得を変更していないことが分かる。

同じテストは設定sequenceのiterator getterを一回だけ読むことと、要素名がSymbolで変換に失敗した際のIteratorCloseも確認する。getHTMLのshadowRootsと同様、設定をnativeへ送る前のWeb IDL変換に、独立した観測点がある。

templateとclosed rootも、同じfilterに通す

filteringの実装は、VisitとUnwrapのtaskをstackで処理する。許可したElementでは属性をfilterし、通常の子に加え、shadow rootとtemplate contentもtaskへ入れる。削除するElementはそのsubtreeごと取り除く。replaceWithChildren対象では、先に子をfilterしてから、子を親へ移し、外側のElementを外す。

templateとshadowの回帰は、closed・serializable rootの中、普通のtemplate contentの中、SVGの属性を一つのmarkupへ含める。runScriptsをtrueにしてもscriptの実行回数はゼロで、secret属性は消え、SVGのxlink:hrefのjavascript URLは消える。一方、通常のhrefのhttps URLは残る。

コメントを削ると隣接Textができるが、今回のfilterは自動でnormalizeしない。隣接Textのテストは、a <!-- comment --> bからCommentを消した後も、a側とb側のTextが二つ残ることを確認する。出力文字列が同じならTextをまとめてもよい、という変更をここへ混ぜていない。

namespace、ASCII大小、URLを分けて判定する

設定中のNameはnamespaceとlocalNameを等価判定のkeyにする。prefixはkeyにしない。同じxlink namespaceのhrefを別prefixで書いた場合にも、属性の意味は同じだからだ。逆に、localNameが同じだけの別namespace属性をまとめて削ると、別の属性まで消してしまう。

javascript URLの確認は、定数で指定されたnavigatingな要素・属性の組と、MathMLのhrefなどを対象にする。URL parserでschemeを調べる。SVGのanimate、animateTransform、setでは、attributeNameがhrefやxlink:hrefを示す場合も扱う。foreign属性のテストは、viewBox、xml:space、xlink:href、xmlns:fooの保持と、safe側でのattributeNameの除去を具体的に確認する。

event handler名の大小変換はASCIIだけを使う。Unicodeの一般的なcase foldで、Kelvin signのKをkと同じ扱いにすると、onKeydownのような別名を実行可能なhandlerへ変えてしまう。その回帰テストは、safeな設定でonkeydownを除き、onKeydownは別属性として残し、イベントをdispatchしても実行されないことを見る。通常のASCIIのONKEYDOWNはunsafeの挿入でhandlerとして働く。

getのsnapshotの順序にもUTF-16を使う。U+10000とU+E000はUnicode scalarの数値順とUTF-16 code unit順が違う。順序のテストはその並びと、公開名がjavascriptURLsであることを確かめる。内部のserdeのcamelCaseに任せてjavascriptUrlsを出すと、APIの名前まで変わってしまう。

定数はWPTの期待でなくHTML Standardのsnapshotから生成する。READMEには取得日、sourceのSHA256、生成方法と、122のdefault要素、58のglobal属性、8のunsafe要素、7のnavigatingな組を記録した。これらはこのrevisionのsnapshotの範囲だ。 README

UTF-16の文字列を、RustのStringへ丸めない

HTMLをJavaScriptから受け取る時には、文字列が必ずUnicode scalarだけでできているとは限らない。JSのstringはUTF-16のcode unit列で、単独のhigh surrogateやlow surrogateも保持できる。RustのStringはその値を直接表せない。この違いを境界で無視すると、parseやserializeの往復で元の値が変わる。

説明用に、次の四つは別の文字列だ。

'\ud800'    // 単独のhigh surrogate
'\ufffd'    // 実際のreplacement character
'\\uD800'   // バックスラッシュとuD800という文字
'😀'         // 正しいsurrogate pair

最初の値をString::from_utf16_lossyで変換すると、二つ目と同じreplacement characterになる。一度同じRustのStringにした後では、どちらが入力だったかを取り戻せない。JSONのescape表示を利用しても、文字列としてのバックスラッシュと、実際のsurrogateを取り違える危険がある。

今回の実装は、nativeへ渡す段階からUTF-16の列を取り出し、元のcode unitを保持する。通常のscalar文字列で表せる値は既存のStringを使い、表せない場合はUTF-16の列も保存する。Text、Comment、ProcessingInstruction、CDATA、属性値、parse token、serializerの出力まで、橋の片側だけで終わらせずにつなげた。

tokenizerの状態機械と、元のcode pointの種類を分ける

InputCodePointはScalarとSurrogateの二つを持つ。状態機械が比較するcharと、入力から来た元の種類を別にする。surrogateを状態機械でreplacement characterのように見せる場合でも、TextBufferへ入れる時には元のunitを使える。

tokenizerのreconsumeでも元の種類を残す。専用テストはSurrogateの0xD800とScalarのU+FFFDを並べ、consumeで見えるcharが同じでもconsumed_code_pointは別で、reconsume後もsurrogateの情報が残ることを確認する。 tokenizerのテスト

InputDecoderは末尾のhigh surrogateをpendingとして保持する。次のchunkの先頭がlow surrogateなら二つを組にし、scalarへ変換する。そうでなければ先のhighを単独Surrogateとして出し、次のunitを改めて処理する。入力の終わりではpendingを単独Surrogateとして出す。finishをもう一度呼んでも同じ値を二重に出さない。

inputモジュールのテストは、0xD800、<、U+FFFD、単独low、正しい絵文字のpair、連続high、リテラルの\uD800を含む列を使い、すべての分割位置で二回のpushへ分ける。その間に空chunkも挟む。最後に元のunit列へ戻して、入力と完全に一致することを確認する。正しい文字が一つ読めるだけでは、streamの境界をまたぐ不具合を見つけられない。

pending inputを一旦取り出して戻す経路も、UTF-16のまま扱う。scriptの再入などでparserの入力を退避するとき、Stringへ一度変換したら、その時点で単独surrogateは失われる。同じtokenizerテストには、その退避・復元後にも元の列が残り、後からlow surrogateを加えられることの確認がある。

data、raw text、属性、Commentで保存先をつなぐ

通常のdataだけ対応しても、textareaやtitleのRCDATA、styleやscriptのraw text、plaintext、属性値、Commentで値が変われば、一貫したAPIにはならない。tokenizerのTextBufferはUTF-16をownedに保持し、scalarで表せる場合とexact unitが必要な場合のtokenをtree builderへ渡す。属性にもvalue_utf16の経路を持つ。

UTF-16のDOM回帰では、createTextNodeのdata、textContent、length、cloneを確認する。Textの子を複数のElementに分け、Commentを挟んだtextContentも確認する。単独highと単独lowを別Nodeへ置いても、親のtextContentは二つのcode unitを連結した結果になる。

dataをsurrogateからordinaryへ変更した後のcloneも調べる。scalar Stringだけ変え、exact bufferを消し忘れると古い値が戻るためだ。属性もsurrogate、リテラルescape、絵文字、通常文字列を順に入れ、最新の値を返すことを確認する。

parseの回帰はhigh、U+FFFD、low、絵文字、リテラルescapeを混ぜ、Documentとfragmentの両方へ渡す。六種のtext context、SVG、tableのfoster parentingも確認する。属性は三種のquote形式とxlink namespaceを対象にする。

CommentとProcessingInstructionも、入力、dataの取得、deep clone、getHTMLの出力を往復する。XMLSerializerとXMLのinnerHTML、outerHTMLにはさらにCDATAも含める。HTML側だけ直してXML serializerで値を失う、という切れ目を残さないためのテストだ。

escapeの処理そのものをUTF-16で行う

HtmlOutputはVecを出力bufferにする。タグ名や固定markupのscalar文字列はencode_utf16して足す。DOM由来のdataと属性値はunitを読み、escapeするASCII文字などだけを置き換え、それ以外は元のunitを足す。

例えば単独highの後に<&>があるTextなら、surrogateを残したまま&lt;&amp;&gt;へ変換する。raw textではそれも変換しない。属性では&、引用符、angle bracket、nbspをescapeする。angle bracketの回帰は、属性値の単独surrogateと<>が同時に正しく出ることを確認する。

document.writeは、呼び出し途中のTextも観測できる

writeのテストは、まずpの開始とhigh surrogateだけを書く。この時点でpのfirstChildがあり、dataが単独highとして観測できることを確認する。次のwriteでlowと別のhighを加えると、firstChildのidentityは同じで、dataは絵文字と単独highになる。

最初のwriteの結果が観測できることと、次のwriteでpairとしてつながることを両立する必要がある。毎回parseし直して子を作り替える実装は、値が合ってもidentityの条件で失敗する。続く再入scriptがbを追加した後、その後ろのpのText、属性、Commentでも元のunitが残ることを同じテストで確認する。incremental parserの状態を保存することと、DOMへ確定したTextへ追記することの両方を見ている。

ProcessingInstructionを、古いbogus commentと区別する

HTMLの<?processing data?>は、今回対象にしたHTML StandardではProcessingInstructionとして扱うtokenizerの状態がある。古いHTML parserの実装や固定WPTには、これをbogus commentとして扱う期待が残る。この差が、今回のCIで実際に出た。

同じ文字列でも、CommentならnodeTypeは8で、dataへ?processing data?が入る。ProcessingInstructionならnodeTypeは7で、targetがprocessing、dataがdataになる。後からdataを書き換えたMutationRecordのoldValueにも、その差が現れる。

tokenizerの専用処理は、<?の後でtargetを読む。最初はASCII alphabeticかunderscore、その後はASCII alphanumeric、hyphen、underscoreを許す。delimiterが不正ならbogus commentへ戻す。xmlとxml-stylesheetはASCIIの大小を無視して特別に拒否し、これもbogus commentとして扱う。

targetの後のwhitespaceを読み飛ばし、dataをliteralに保存する。data内の&amp;はcharacter referenceとして展開しない。?>または>で終え、入力が途中で終わった場合はUnexpectedEofを記録して、未完成のinstructionを出さない。専用テストは9で始まるtarget、colonを含むtarget、XML、xml-stylesheet、途中EOFと、すべてのwrite分割位置を確認する。

MutationObserverの回帰は、parsedなinstructionと通常Commentを並べ、instructionの種類とtargetとdataを確認する。その後dataをCHANGEDにし、同じCHANGEDをもう一度入れ、replaceDataでDONEにする。三件のrecordのoldValueはdata、CHANGED、CHANGEDで、同じ値への代入も記録される。

SanitizerのProcessingInstruction指定は、targetのcaseを区別する。keepを許す設定でKeepは残らない。dataのampersandを勝手にHTML展開しないこともfilterのテストで確認する。新しいnodeTypeを作っただけでなく、CharacterDataの変更、serializationの終端、filterの名前判定までつないでいる。

固定WPTのMutationObserver-characterData.htmlは古いbogus-commentのdataを期待していた。PRの調査記録では、仕様、固定revisionのfixture、実装を照合し、期待との違いを区別している。fixtureやassertionを新しい期待に直接書き換えて通過扱いにしたわけではない。固定テストの失敗を実装の回帰と即断せず、かといって仕様更新を理由に別の回帰までまとめて許さない、という調査になった。

encodingとcompatModeを、DOMを見た推測にしない

DocumentのcharacterSet、charset、inputEncodingは、現在のmeta要素に書かれた文字列を返すものではない。入力byteをどのencodingでdecodeしたかを返す。metaのcharsetを後から変更しても、すでにdecodeされた本文のencodingが変わるわけではない。

HTML decoderは、decodedなtextと、実際に使ったcanonical encodingをDecodedHtmlとして返す。transportのContent-Typeのcharsetを優先し、それがなければmetaから候補を選ぶ。encoding_rsのdecodeがBOMによって別のencodingを使った場合は、その返されたencoding名を記録する。

TreeBuilderはそのmetadataをDocumentへ渡す。表示している木を後から検索して決めない。encodingのテストは、windows-1252のmetaと0xE9のbyteからéを作り、metaをutf-8へ書き換えてもcharacterSetがwindows-1252のままであることを確認する。

別のテストはtransportのwindows-1252がmetaのutf-8に優先し、Document cloneにも残ることを確認する。decoderのテストはBOMによるUTF-8への変更と、ISO-8859-1 labelをwindows-1252のcanonical名で報告することを確認する。

Document.parseHTMLUnsafeへ渡すJS文字列は、byteのdecode前ではない。meta charsetがlatin2でも、XML declarationにencodingがあっても、これをdecodeし直さない。string inputのDocument encodingはUTF-8として扱う。これは先のexact UTF-16 storageと矛盾しない。DOM stringのunitを保存する処理と、Documentが報告するresource encoding metadataは用途が違う。

compatModeは最初のdoctypeの分類から出す

doctypeがあるかだけを検索して、あればCSS1Compat、なければBackCompatと決めるのも不十分だ。doctype名がhtmlでない場合、tokenizerがforce quirksにした場合、古いpublic identifierやsystem identifierによってfull quirksになる場合がある。

quirksの分類は、initial insertion modeで使う条件を専用のpredicateへまとめる。古いpublic identifierのprefixや特定system identifier、system identifierのないHTML 4.01 transitional・framesetなどを確認する。limited quirksもcompatModeではCSS1Compatを返すので、このpredicateはfull quirksの条件を対象にする。

doctypeのテストは、fooというdoctype、HTMLというpublic identifier、systemなしのHTML 4.01 Transitional、不正なPUBLICなどをBackCompat側に入れる。通常のhtml doctype、後から二つ目の不正doctypeがある入力、XHTML Transitional、system付きHTML 4.01 TransitionalはCSS1Compat側で確認する。

Documentの種類とHTML namespaceは、別のnative情報にする

HTML namespaceに属するElementだから、ownerDocumentもHTML Documentだとは限らない。application/xhtml+xmlのDocumentにもHTML namespaceの要素はある。XML DocumentにsetHTMLUnsafeしてもHTML namespaceの子ができる。逆にHTML DocumentでcreateElementNS(null, ‘Plain’)すれば、namespaceなしの要素を作れる。

今回のDocumentMetadataはencodingとcontentTypeを保持する。is_html_documentはnativeのcontentTypeがtext/htmlかを読む。属性名の変換は、ElementがHTMLかという条件と、ownerがHTML Documentかという条件を両方見る。nativeの判定は、authorのownerDocumentやcontentType getterを呼ばない。

属性正規化の回帰はHTML、namespaceなしXML、XHTMLを分ける。XHTMLのHTML interfaceとnamespaceを保ち、名前と属性はcase-sensitiveにする。createElementとcreateElementNSのprefix/localNameも区別し、colonがあるだけでprefixを推定しない。 属性正規化のテスト群

HTMLの名前の大小変換はASCIIだけで、Unicode全体のuppercase・lowercaseを使わない。cloneはlocalName、prefix、namespaceをそのまま保存する。UTF-16の属性値を持つXHTMLのdeep clone、import、adoptも確認し、caseとunitの両方を落とさない。

__contentTypeのexpandoを書き換えてもnative MIMEと属性規則は変わらない。テストはownerDocument、contentType、Symbol.hasInstanceを例外getterにし、Elementのprototypeをnullにした上でも、保存した属性APIがnativeの規則で動くことを確かめる。

document.bodyは子孫検索ではない

document.bodyをquerySelector(‘body’)で実装すると、headの中のbodyや別namespaceのbody、深い子孫のbodyも拾う。実際にはHTML namespaceのhtml rootの、直接の子から、bodyまたはframesetを順番に探す必要がある。prefixはあってよいがlocalNameのcaseは正確に見る。

nativeのbody getterはDocumentのnativeなrootとchildrenを読む。返るbodyがまだwrapper registryに登録されていないnative挿入でも、query結果の既存のowner登録を通す。読み取りをしただけでMutationRecordをqueueしない。

bodyのテスト群は、複数bodyの並べ替え、framesetの順序、nested body、foreign body、namespaceなしbodyを比べる。XHTMLではh:htmlとh:bodyを受け入れ、BODYやHTMLの大文字localName、namespaceなしのroot、SVG rootは対象にしない。

bodyを別DocumentからreplaceChildすれば古いDocumentのbodyはnullになり、新しい側は同じNodeを返す。rootを置き換えれば新rootのframesetを返す。公開のchildrenやlocalNameを例外getterにしてもnative getterは動く。偽DocumentやElementはTypeErrorで、別Realmの正規Documentはidentityを保って受け入れる。

XHTMLのUA styleにもHTML namespaceを使う

CSSのbodyの既定marginやhidden属性は、HTML constructorで作ったという印だけに依存させない。XHTML parserで作った要素にもHTML namespaceがあるので、同じUAの規則を適用する。UA candidateの処理は、HTML namespaceか、namespaceを省略した旧native HTML elementかを判定する。

bodyのlocalNameが正確にbodyなら四辺に8pxのmarginをUA originのcandidateとして入れる。XHTMLのx:bodyも対象だが、XMLのBODY、SVG namespaceのbody、独自namespaceのbody、namespaceなしXMLのbodyは対象にしない。HTML Documentかどうかの条件と、HTML namespaceのUA styleの条件は同じではない。

hiddenでは、until-found以外はdisplay:none、until-foundはcontent-visibility:hiddenのcandidateを使う。これをlayoutの特別な強制値にすると、author CSSで上書きしたりrevertでUAへ戻したりするcascadeを壊す。UA originとして入れるため、通常のauthor ruleが勝ち、revertはUAの値を見せる。

styleのテストはXHTML bodyのmarginとhiddenを確認する。hiddenの追加回帰は空文字、hidden、不正な値、UNTIL-FOUNDを比べ、authorのdisplay:blockやcontent-visibility:visibleが勝つこと、その後inlineのrevertでUA値へ戻ることを確認する。ここで確認したのはcascade上の値であり、until-foundに関係する検索UIの機能全体を実装したという意味ではない。

選択範囲を、描画でまとめたTextから元のDOMへ戻す

最後にDOM側の変更とつながるのがmouse selectionだ。描画では、隣接TextやCommentをまたぐTextをまとめてshapingする場合がある。そのまとまった表示文字列のindexをそのままRangeへ入れると、元のDOMのどのTextにあるoffsetなのかが分からない。

例えばTextがAlpha、その後にComment、さらにTextがβ😀なら、表示はAlphaβ😀でも元のTextは二つだ。後半TextのlengthはUTF-16で3になる。絵文字を一つのscalarとして数えて2にしたり、先頭Textのoffsetへ全体の長さを入れたりすると、選択範囲が不正になる。

この選択のテストは、layoutしたpへmousedown、mousemove、mouseupをnativeの入力経路から渡す。anchorは最初のTextの0、focusは後半Textの3、toStringはAlphaβ😀で、Rangeは両Textと交差することを確認する。DOMの作りから選択を直接設定したテストとは違い、表示位置をhit testした結果から元Nodeへ戻るところを通す。

text sourceの対応情報は、正規化した文字でも元のUTF-16の範囲を保持する。mouse側のnative caret hit testは、現在のDocumentのlayoutとstyleを使ってNodeとoffsetを返す。top-levelだけでなくchild DocumentではそのDocumentのlayout、scroll、viewportを使う。

synthetic eventと実入力の既定動作を区別する

ページのdispatchEvent(new MouseEvent(...))だけで、実際のドラッグ選択を始めるわけではない。キャンセルとsyntheticの回帰は、既存SelectionをTextのoffset 2に置き、syntheticなmousedownでは変わらないことを確認する。

native入力でもmousedownをpreventDefaultした場合は選択を変えない。右buttonの入力でも同じだ。selectstartはTextの境界へ発行され、bubbles、cancelable、isTrustedを持つ。そのeventをpreventDefaultすれば、元のSelectionと関連Rangeをそのまま保つ。selectstartのテストはこの条件を確認する。

逆方向のドラッグではanchorとfocusが逆になる。Rangeのstart/endの順序とSelectionの向きは別に持つ必要がある。backwardの回帰は、backwardというTextでanchor offset 8、focus offset 0を確かめる。

authorのRangeメソッドを、既定動作の内部で呼ばない

mouseの既定動作が公開Range.prototype.setStartやSelection.prototype.setBaseAndExtentをそのまま呼ぶと、ページがそれを上書きして選択の内部処理まで止められる。今回の回帰はこれらを例外関数に置き換え、TextのdispatchEventも上書きする。その状態でもnative入力で文字列全体を選べることを確認する。

Rangeのoffset validationもnativeのCharacterDataのlengthとnative childrenの数を使う。境界の回帰は、a😀bのTextのlength、nodeType、parentNodeとhostのchildNodesを例外getterにする。offset 1から3は絵文字を含む正しい範囲として受け入れ、5はIndexSizeErrorになる。CDATAもUTF-16の同じ境界として扱う。

shadow内のドラッグはcomposedな境界を扱い、iframeの入力はchildのSelectionだけを更新する。83件のDOM回帰には、この選択、Range、別Realmも含む。追加メソッドの名前を83通り確かめた数字ではない。

ページ自身が例外を観測するための経路

DOM操作の追加と並行して、このPRではページ内のJavaScriptからエラーを観測する経路を実装した。Issue #1289の最初の確認では、ErrorEvent、PromiseRejectionEvent、reportErrorがいずれも存在せず、window.onerrorやwindow.onunhandledrejectionもIDL属性として用意されていなかった。Rust側で実行失敗を記録できることと、ページがその失敗を自分のlistenerで受け取れることは別だ。例外が外から見えても、ページ内のテストハーネスや監視コードが待っている通知が来なければ、そのページにとってはAPIが足りない。

どのglobalへ何を渡すかに加え、callbackのRealm、取得時のCORS、Worker境界を保つ必要がある。console向けの文字列だけでは、通知先と元の値を復元できない。

実装の中心はsrc/js/script_errors.rsだ。ここでBoaのエラー、Documentのidentity、scriptの取得由来、Realmに登録した非公開のreporterをつなぐ。DOMのeventを構築する側はdom_bootstrap.jsのreporterに置いている。Rust側が実行の失敗を見つけ、JavaScript側がそのRealmに属するevent interfaceで通知する構成になった。

scriptが失敗しても、次のscriptを止めない

scriptの例外はその評価を止めるが、後続のscriptは別の実行になる。document_script_exception_reports_to_global_and_keeps_following_scriptは、その区切りを確認する。

<script>
  globalThis.log = [];
  globalThis.thrown = { message: "script" };
  onerror = (message, filename, line, column, error) => {
    log.push(error === thrown ? "error" : "wrong");
    return true;
  };
  throw thrown;
</script>
<script>
  log.push("next");
</script>

期待値はerror,nextだ。最初のscriptを例外として処理し、globalへ通知してから次のscriptを実行する。eventのerrorには元の値を保つので、error === thrownも通る。

文書scriptの経路には、最初からHTMLにあるscript、deferなどで後から評価するscript、DOMへ追加されるscriptがある。このPRは、非公開reporterを一つ追加した後、これらの実行入口から同じ通知処理へ渡すようにしている。取得時に分かったmutedの情報も入口ごとに渡すため、同期評価だけを直して非同期評価では詳細が漏れる、という状態を避けている。

module評価の失敗後にも、後続のclassic scriptが動くことはerror_reporting_worker_module.rsのテストで確認する。このテストの主な対象は継続と診断の秘匿であり、moduleの全例外通知を網羅するassertionではない。

timerでは現在のcallbackだけを失敗させる

timerでは一つのcallbackが失敗しても、後続taskを実行する。

const log = [];
const thrown = { message: "timer" };
addEventListener("error", event => {
  log.push(event.error === thrown ? "error" : "wrong");
  event.preventDefault();
});
setTimeout(() => {
  log.push("timer");
  throw thrown;
}, 0);
setTimeout(() => log.push("next"), 0);

timer_callback_exception_reports_original_value_and_keeps_next_taskの期待値はtimer,error,nextになる。例外の通知は最初のcallbackの失敗処理の中で行われ、次のtaskはその後に進む。isTrustedも確認していて、ページが自分でnew ErrorEvent(...)してdispatchしたeventと、hostが実行失敗から出したeventを区別できる。

Rust側ではrecord_error_fromがtimerの失敗を受け、script_errors::report_exceptionへ渡す。通知をキャンセルしたかどうかは、その後のWorker owner向け転送の判断にも使う。同時に、生のtask診断は別の記録として残す。この二つが同じ配列と同じ条件で処理されると、ページが通知をキャンセルしただけで診断まで消えたり、同じWorker例外を別の経路でもう一度送ったりする。

生のtask診断には件数上限があり、壊れたsetIntervalによる無制限の蓄積を防ぐ。超過分は件数として記録し、後続taskの実行は止めない。

event listenerの通知先は、event targetだけでは決まらない

event listenerでは、eventをdispatchした場所と、callbackを作ったRealmが一致しないことがある。親文書のbuttonに、子iframeのFunctionで作ったcallbackを登録できる。同一originでアクセス可能な状況なら、次のような組み合わせを作れる。

const child = document.querySelector("iframe").contentWindow;
const target = document.querySelector("button");
child.addEventListener("error", event => {
  console.log("child", event.error);
  event.preventDefault();
});
target.addEventListener("probe", child.Function("throw 42;"));
target.dispatchEvent(new Event("probe"));

この例外は、buttonのowner Documentを見て親globalへ送るのではなく、callbackのRealmに属するglobalへ報告する。listener_exception_reports_to_the_callbacks_realm_globalでは親にも子にもerror listenerを置き、記録がchildだけになることを確認している。

Boaがcallbackの呼び出しから戻ると、現在のRealmも呼び出し元へ戻り得る。通知先を失わないよう、callbackのDocumentを呼び出す前に保存する。

call_event_listener_nativeは、呼び出す前にlistenerのRealmを求める。関数ならget_function_realmを使い、callback objectならassociated Realmを使う。そのRealmのhost metadataからDocument identityを取り、失敗後の通知にも同じidentityを渡す。report_exception_for_documentという入口があるのは、通知処理を始めた時点のContextからDocumentを引き直さないためだ。

listenerが関数とは限らないことにも対応する。

const listener = {
  get handleEvent() {
    throw { message: "getter failed" };
  }
};
target.addEventListener("probe", listener);

handleEventの取得失敗もlistenerの失敗として報告する。callback本体の結果を受け取るnative continuationにはDocument identityと以前のwindow.eventを持たせ、戻った時に復元してから報告する。取得と呼び出しの両方を処理する。

queueMicrotaskのthrowを、Promise rejectionへすり替えない

queueMicrotaskとPromiseのreactionは、同じmicrotaskの処理順へ入る。しかし、callback内で投げた例外の行き先は同じではない。Promiseのthenに渡した関数が投げた値は、通常、そのthenが返すPromiseのrejectionになる。queueMicrotaskへ渡した関数の例外は、globalへの例外報告になる。

OmoikaneのqueueMicrotaskは、もともとPromise.resolve().then(...)を使って順序を作っている。ここでcallbackをそのままthenへ渡すと、例外までPromiseの失敗に変わってしまう。今回のqueueMicrotaskのwrapperは、reaction内でnativeのmicrotask callback実行関数を呼び、その関数が通常の例外を捕まえて報告する。

queueMicrotask(() => {
  log.push("first");
  throw sentinel;
});
Promise.resolve().then(() => log.push("promise"));
queueMicrotask(() => log.push("last"));

microtask_exception_reports_error_and_preserves_remaining_job_orderでは、error listenerを含めた記録がfirst,error,promise,lastになる。最初のmicrotaskで例外が出ても、残りのjobを失わない。順序も、後からまとめてerror taskを出した場合とは違い、最初のcallbackの失敗を処理する位置で通知する。

native側の__omoikane_run_microtask_callbackはcallbackを引数なしで呼ぶ。普通の例外ならreport_exceptionへ渡した後にundefinedを返す。このため、queueの順番を作るために使った内部Promiseに、ページ側のcallbackの例外がそのまま流れ込まない。

runtime limitは通常の例外報告へ変えず、Errでjob executorへ返す。hostの中断を成功扱いしないためだ。

ErrorEventへ何を渡すか

JavaScriptはErrorに加え、42、undefined、任意のobjectも投げられる。元の値を保つ処理と、表示用のmessageを作る処理を分ける。

event_interfaces.jsではErrorEventの5フィールドを定義する。errorは元のJavaScript値を保持する。messageとfilenameの文字列変換は同一ではなく、messageには単独surrogateも残り得る。この違いはWorker境界でも効く。

message getterが壊れていても、元の値を通知する

throwされたオブジェクトのmessageを読むこと自体が、ページ側のコードの実行になる場合がある。messageがgetterなら、そのgetterも例外を投げられる。toStringも同様だ。

const original = {
  get message() { throw 99; },
  toString() { throw 100; }
};
addEventListener("error", event => {
  console.log(event.error === original);
  event.preventDefault();
});
reportError(original);

この状況でmessageを作る失敗を、そのまま「元のエラー通知の失敗」にすると、最初の値は届かなくなる。exceptionMessageは、保存したintrinsicのStringでerror?.message ?? errorを変換し、その変換が投げたら空文字列へ落とす。一方、eventのerrorには元の値を入れる。messageが空だからといって、元の値がnullへ変わるわけではない。

hostile_error_message_does_not_prevent_reporting_original_valueは、まさにこのオブジェクトを渡して同一性をassertする。getterが投げた99や変換が投げた100の方をeventへ入れる実装では通らない。

message取得ではgetterを一度実行し得る。失敗しても元の値を保ち、Worker転送でも確定済みのmessageを使って再実行を避ける。

onerrorだけは五つの引数とtrueの返値を扱う

addEventListener("error", ...)のcallbackは、一つのeventを受け取る。window.onerrorは歴史的に異なる呼び出し方を持ち、message、filename、line、column、errorの五つを受け取る。さらに、通常のevent handlerではfalseがキャンセルを意味する場面があるのに対して、この特別なonerrorではtrueがキャンセルになる。

onerror = function(message, filename, line, column, error) {
  console.log(arguments.length); // この経路では5
  return true;
};
addEventListener("error", event => {
  console.log(event.error);
});

event_handlers.jsのinvokeHandlerは、error型、global自身というtarget、messageフィールドの存在を見て特別な呼び出しを選ぶ。ErrorEventでglobalへ報告するときだけ、五つの引数を作る。返値もtruthy一般ではなく、厳密にtrueかどうかでpreventDefaultする。

つまりworker.onerror = event => ...のように、ページにあるWorkerオブジェクトへ付けたhandlerまで五引数にしてはいけない。Workerオブジェクトはglobal自身ではないので、そこで受け取るのはeventだ。また、読み込み失敗のplain Event("error")にはmessageフィールドがない。そのeventまで例外の五引数形式へ変えると、load failureとruntime exceptionの区別が壊れる。

キャンセルは、event listenerの残りを止めることとも違う。preventDefaultは既定処理を抑えるもので、stopImmediatePropagationではない。report_error_dispatches_synchronously_with_original_value_and_onerror_argumentsでは、onerrorがtrueを返しても、併せて登録したerror listenerが呼ばれ、二つの記録が残ることを確認している。キャンセルを「誰もこの後は観測しない」と読み替えないことが大事だ。

reportErrorはthrowの代用品とは違う

reportError(value)は、値をglobalへ報告する関数だ。呼び出し元からその値をthrowし直す関数ではない。通知は同期的に行われ、通常はundefinedを返す。

const log = [];
addEventListener("error", event => {
  log.push("reported");
  event.preventDefault();
});
log.push("before");
reportError({ message: "explicit" });
log.push("after");
// 説明対象の実装では before,reported,after

実装はregisterのnative callableから、渡された値をJsError::from_opaqueで包んで通常のreporterへ渡す。ここでopaqueというのは「messageに潰した文字列」ではなく、元のJavaScript値を保ったエラー表現だ。DOM bootstrap側ではこのnative callableをglobalThis.reportErrorとして公開し、関数名も合わせている。

引数なしと、明示的なundefinedも分ける。reportError()は必要な引数がないのでTypeErrorになる。一方、reportError(undefined)はundefinedという値を報告する。Rust側がargs.first()の有無を確認するのは、この差を保持するためだ。report_error_requires_an_argument_but_accepts_undefinedは両方を同じテストで確認している。

通知の途中で、通知経路を書き換えられないようにする

globalへeventを出す処理をwindow.dispatchEvent(event)というproperty lookupで実装すると、ページがその関数を書き換えただけでhostのエラー通知が壊れる。

window.dispatchEvent = () => {
  throw new Error("author override");
};
reportError(42);

reporterは保存したplatform constructorと内部dispatcherを使う。host_error_reporting_ignores_author_dispatch_overrideでは、公開dispatchEventの差し替え後も42がtrustedなeventとして届く。

また、error listener自身が例外を投げると、報告のための報告を無限に繰り返す可能性がある。reportingEventListenerErrorというguardは、reporterがすでに動いている間の再入を検知する。その場合は追加のeventを再帰的に作らず、consoleへの出力を試みて戻る。guardはfinallyで解除するため、今回の通知中に失敗しても、次の独立した例外の報告まで止まったままにはしない。

例外の位置は、Errorオブジェクトのpropertyから推測しない

filenameや行・列をErrorのpropertyから取ると、任意のthrow値やgetterに対応できない。throw 42にも実行位置はあるので、エンジンの情報を使う。

今回Boaへ追加したJsError::source_locationは、エンジンが持っているbacktraceから最も近いJavaScriptのbytecode frameを探し、program counterをsource mapへ対応させる。そこで分かったsource pathと一基準の行・列を返す。ページ側のError propertyは読まない。

位置がないエラーもあるので、返値はOptionになっている。Omoikane側は位置がなければ行・列を0にし、filenameには対応するDocument URLを使う。URLの解決基準として使うbase URLと、文書自身のURLは分ける。<base href="https://other.example/">があっても、文書内で作ったtimerの例外filenameがその外部baseへ変わるべきではない。timer_error_filename_uses_document_url_instead_of_base_elementは、fragment付きのDocument URLが保たれることを確認する。

callbackが呼ばれる時刻ではなく、throwの位置を残す

callbackは、最初のscriptの評価が終わった後に呼ばれる。その時の呼び出し元のsourceを位置に使うと、timerのthrowがtimerを処理する内部scriptの行に見えたり、later()の呼び出し位置だけになったりする。回帰テストは、通常のthrow、未定義identifierを読むReferenceError、Rustから直接呼んだcallbackを分けている。

二改行の後にmissing_identifier;を読むテストでは3行1列、timer callback内のthrowでは3行目を報告する。ErrorのpropertyではなくVMの実行位置を使う。

inline scriptでは、取り出したJavaScriptを1行目からparseすると、HTML全体における行番号が失われる。今回のSource::with_start_positionは、埋め込み側が開始位置を渡せる入口だ。scriptの最初の位置を30行7列にしてcallbackをcompileし、そのcallbackの二行目でthrowするテストでは31行目になる。

inline_script_timer_error_keeps_absolute_html_line_and_document_urlには、HTML commentの中の偽の<script>も入っている。単純な文字列検索でscript開始位置を拾えば、ここで間違える。実際のscript nodeに対応する開始位置を使い、後から動くtimerでもHTMLの絶対行とDocument URLを保つことを確認している。

位置はcompileしたsourceに対応する。bundleを元のTypeScriptへ戻す外部source map処理は、この変更の対象ではない。

位置を読む入口だけでなく、エンジン内部のsource mapを組み立てる処理も直している。式が深く入れ子になると、内側の式が終わった後に外側の位置へ戻す必要がある。修正前は、子scopeの最後のrangeと子scope全体の開始位置を同じように扱っていたため、孫scopeのある形でrangeが重なり、throwの位置を失い得た。

SourceMapBuilderは、scope全体のstartと、現在開いているentryを分けて持つようにした。子scopeが閉じると、親のrangeを子全体の開始位置で閉じ、子の終了位置から親の位置を再開する。三つのscopeが同じPCから始まるテストでは、PC 0で最内側、5で一つ外側、10で最外側の位置へ戻ることを確認する。開始PCが0、2、4とずれるテストでは、それぞれの前後の範囲が重ならないことも確認する。throwの位置を失う原因がmapの組み立てにある場合、reporterでpropertyを読み足しても修正にはならない。

scriptの取得由来は、unwindする前に保存する

CORSなしで取得したscriptが登録するcallbackにも、元の取得由来を保つ必要がある。scriptの初回評価が終わっても、その制約は残る。

BoaにはScript::parse_with_host_definedを追加した。parse時にscript自身のhost metadataを渡し、そのscriptから作られた関数が後で動く時にも、active scriptとして取得できるようにする。OmoikaneはここへClassicScriptMetadata { muted_errors }を置く。

しかし、callbackの実行が例外で終了し、host側へ戻った時点では、active scriptがすでに外れている場合がある。そこでhandle_throwが、最初にbacktraceを付ける際、投げたscriptの診断metadataもJsErrorへsnapshotする。report_exception_for_documentはこのsnapshotを優先し、なければ現在のactive scriptへfallbackする。

snapshotがscriptそのものを強く所有すると、エラーを保存しているだけでRealmやDOMを含む大きなgraphまで残る可能性がある。ScriptErrorMetadataは、immutableなhost診断情報をArc<dyn Any + Send + Sync>へ置く。Boaのthread-localなGC handleをpayloadへ入れない制約を付け、エラーに必要な由来だけを保つ。

scriptとContextをdropして強制GCした後も、保存したエラーのmetadataを読めることを確認する。逆に、metadataからエラーへ戻るcycleは外部所有者をdropした後に回収される。診断の保持がscript全体の永久rootにならないことも検証する。

CORSなしのclassic scriptをどう報告するか

外部scriptの例外は、すべて同じ詳細でページに見せるわけではない。今回のmuted errors経路では、cross-originからCORSなしで取得したclassic scriptの例外を、message = "Script error."、空のfilename、行・列0、error = nullへ変えて通知する。同じscriptからの未処理rejectionは通知対象から外す。

fetchのresponse typeからmutedを決める

fetch_classic_sourceは、source文字列だけでなく、実効URL、redirect回数、mutedの真偽値を返す。crossorigin属性があればCORS mode、なければno-CORS modeを選ぶ。credentialsも、use-credentials、それ以外のcrossorigin値、属性なしで分ける。

このfetchを通した結果のresponse typeがOpaqueならmutedにする。比較に使うoriginは、Documentのimmutableなsecurity originから求める。resourceのbase URLを書き換えたことや<base>を置いたことは、Documentのsecurity originを変えることにはならない。実効URLはredirect後のresponseから取り、sourceの位置情報へ渡すURLも合わせる。

回帰テストはloopback HTTP fixtureを使い、ページとscriptを異なるoriginに置いている。script側ではPromise.reject('private rejection')の後に例外を投げる。ページ側はerrorとunhandledrejectionの両方を監視し、結果がScript error.||0|0|trueだけになることをassertする。最後のtrueはevent.error === nullだ。非公開のreasonがrejection eventから漏れた場合は別の記録が増えるので、このassertionでは隠せない。

対照として、crossoriginを付けたのにCORS許可のないscriptは、取得失敗として実行しない。cross_origin_cors_script_without_permission_does_not_executeは、sourceに仕込んだglobal propertyが作られていないことを確かめる。CORS失敗をno-CORSへ自動的に格下げして実行する処理にはしていない。

同じURLでも、callbackに付く由来は別になる

URLをキーにして「このscriptはmuted」と記録するだけでは、同じURLを異なる設定で取得した場合に上書きされる。same_script_url_keeps_distinct_cors_metadata_in_retained_callbacksは、同じsame.jsを、属性なしとcrossorigin="anonymous"で一度ずつ読み込む。

返すsourceは両方とも、後からPromiseをrejectするcallbackを配列へ追加するものだ。fixtureは二回のrequest headerも記録する。最初のno-CORS requestにはOrigin headerがなく、次のCORS requestにはページのOriginがあることを確認する。responseにはCORS許可を付け、二つのcallbackを初期評価の後に呼ぶと、rejection通知は一つだけになる。

metadataはそれぞれのScript recordが持つ。先に作ったcallbackはmuted、後のcallbackはCORS許可済みという別の由来を保つ。URL cacheで一つにまとめない。

最初の評価が終わった後の経路も別々にテストしている。timer、event listener、microtask、defer、動的追加scriptの例外が、それぞれmutedの形で届く。reportErrorにも同じ由来を適用する。ただし、reportErrorにmutingを適用するかは、現在のHTML仕様にもブラウザ間の差が残ると記載されている。ここで説明しているのはOmoikaneの選択とその回帰テストであり、全ブラウザに共通する確定した挙動だと広げてはいない。

Promise trackerでは、RejectだけでなくHandle側も現在動いているscriptのmutedを見て抑える。visibleなscriptが作ったPromiseへ、後からmutedなscriptの関数がcatchを付ける場合もある。その操作のscriptを見ずに、Promiseを作ったURLだけで判断すると、この境界を取り違える。script_errors.rs末尾のテストは、保存したmuted callbackからRejectとHandleの双方を行い、後者によるhandled通知も漏れないことを確認している。

importScriptsの同期例外も、取得由来を保つ

WorkerのimportScriptsは、読み込んだclassic scriptを同期的に実行する。通常の同一originなどの経路で、そのscriptが元の値をthrowしたなら、呼び出し側のtry/catchは同じ値を受け取る。非同期のowner eventへ一律に変えるAPIではない。

worker_imports.rsでは、先にすべての引数を文字列へ変換し、取得したsourceを所有してから順に評価する。各sourceを先ほどのmuted metadata付きでparseするので、読み込んだscriptが後で使うcallbackを作っても、その由来が残る。

一方、mutedなimported scriptが投げた値は、importScriptsの呼び出し元へそのまま渡さない。native側が失敗を表すfalseを返し、WorkerのwrapperがNetworkErrorを作る。これにより、ErrorEventだけを伏せながら、同期のcatch経由ではprivateな値を取り出せる、という穴を作らない。runtime limitはここでも通常の値に変えずに伝播させる。

data URLのimportでは元のsentinelの同一性を確認する。HTTPのnon-CORS importでは、保存したcallbackのrejectionを通知せず、privateなthrow値も取り出せないことを確認する。

Workerの内側と、Workerを作った側を分ける

Workerでは、まずWorkerGlobalScope自身へ例外を報告する。そこでキャンセルされなかったDedicated Workerの例外は、作成元にあるWorkerオブジェクトへ送る。さらにそのeventもキャンセルされなければ、作成元のglobalへ進む。ページに届く時には、Worker内のthrowされたオブジェクトそのものを渡さない。

runtimeのErrをownerへ直送するとWorker内のキャンセルを無視する。例外後に常に終了させれば登録済みlistenerを失うので、起動stageとキャンセルを別に扱う。

parse失敗と、実行中に投げたSyntaxErrorは違う

evaluate_worker_initial_scriptは、parseとevaluateを分け、Completed、ParseFailure、Exception(JsError)を返す。hostのruntime limitはこの三つに入れず、外側のErrとして返す。

// source自体がparseできない
postMessage("must not execute");
const = ;

// sourceはparseでき、実行してから例外を投げる
onmessage = event => postMessage(event.data);
throw new SyntaxError("runtime syntax");

前者は文法解析で失敗するので、先頭のpostMessageも実行しない。ownerへはtrustedなplain Event("error")を送り、ErrorEventにはしない。このeventはcancelableではなく、preventDefaultを呼んでもdefaultPreventedはfalseのままだ。fetchやscriptの読み込み条件に失敗した場合も、このload failure経路を使う。

後者は通常の実行時例外だ。先にmessage listenerを登録済みなので、例外を報告した後もmessage loopを保つ。worker_runtime_thrown_syntax_error_keeps_the_message_loopは、runtime syntaxというcancelableなErrorEventと、送ったaliveへの返信の両方をassertする。error.name === "SyntaxError"だけでparse失敗を推測する実装では、この二つを区別できない。

通常の起動時例外は報告してSome(())へ戻し、登録済みlistenerのイベントループを残す。throw以降の初期scriptを再開するわけではない。parse失敗とhost abortは終了状態へ移す。

ownerへ渡すのは、確定済みのUTF-16フィールドだけ

Worker内のError objectを、そのまま別のruntimeのtask queueに積むと、そのobjectとRealmの寿命まで境界を越えて保持することになる。今回のWorkerErrorReportが持つのは、所有したmessageとfilenameのJsString、lineとcolumnだけだ。owner側のErrorEvent.errorはnullにする。

messageはUTF-16を保つ。String.fromCharCode(0xd800)の単独surrogateをRustのUTF-8 Stringへ丸めると、長さ1の元のcode unitがreplacement characterへ変わり得る。

worker_owner_receives_original_utf16_message_without_repeating_getterは、message getterが呼ばれた回数と、このcode unitを両方確認する。Worker内のgetterは一度だけ呼ばれ、ownerには長さ1、先頭の値55296、元のfilenameが届く。ownerへ送るときにもう一度getterを読む実装も、UTF-8へ丸める実装も通らない。

dispatch前に確定したmessageなどをsnapshotし、転送へ使う。listenerがeventのmessageを書き換えても元の値を送る。一方、defaultPreventedはdispatch後の結果を読む。内容の確定と、転送の判断を分けている。

キャンセルされなかった報告をownerのglobalへ進める

report_worker_owner_errorは、ownerのRealmに登録されたreporterを呼び、まずWorkerオブジェクトをtargetにする。そのeventがキャンセルされなければ、同じsnapshotでownerのglobalへ報告する。ownerがさらに別のDedicated Workerなら、この流れを上のownerへつなぐ。

この転送はDOM tree上の通常のbubbleで済ませているわけではない。Workerオブジェクトのeventとglobalのeventは、内部処理で別に作る。回帰テストは、Worker object側でpreventDefaultするとglobalには来ず、キャンセルしなければ元のフィールドがglobalへ届くことを確認する。さらに、子iframeが作ったWorkerの例外はそのiframeのglobalへ届き、topのglobalには直接来ないことも確認する。

constructorやdispatcherにも、そのowner Realmの保存したplatform interfaceを使う。ページがErrorEvent、EventTarget.prototype.dispatchEvent、worker.dispatchEventをすべて差し替えても、hostの報告は元のinterfaceで届く。iframe内で作ったeventが、そのiframeのErrorEventに属することも保つ。

起動時には、JavaScriptのWorker objectがnativeのruntime生成後にbindされるという隙間がある。その間にreportError(...); close();が実行される場合でも、すでにqueueへ置いた報告を捨てない。Task::WorkerErrorはownerとRealmを持てる形になり、bind_worker_error_ownerが未bindのtaskへ後から受取先を付ける。runtimeのregistry entryが終了したことと、作成済みの通知taskが配送できることは別だ。worker_error_queued_before_startup_close_reaches_its_ownerは、この順番で元のmessageが一度届くことを確認する。

SharedWorkerのruntime例外を全ownerへ複製しない

今回の実装では、SharedWorkerのruntime例外はSharedWorkerGlobalScopeで報告し、ページにあるSharedWorkerオブジェクトのonerrorへは転送しない。fetchやparseのload failureとは別の扱いだ。shared_worker_startup_exception_uses_global_handler_and_keeps_connect_listenerは、初期scriptのthrowがglobalの五引数onerrorへ届き、その後のconnectとportの返信も続くこと、owner側のerror件数が0であることを確認する。

キャンセルされなかったruntime例外には、ページ向けのeventとは別に、SHARED_WORKER_STARTUP_FAILEDやSHARED_WORKER_RUNTIME_FAILEDという診断を記録する。ページ側のmessageやsourceをそのまま診断DBへ持ち込まず、category、code、stageで記録する。error_reporting_worker_module.rsでは、明示報告、microtask、timer、message listenerからのthrowを試し、globalのキャンセルを尊重することとsecret文字列が記録されないことを検証する。

この時点のSharedWorkerは同じBoa thread上のregistryをowner taskの間でpumpする実装だ。複数接続がtimerのclockを二重に進めないこともテストする。別threadの並行実行を検証した結果ではない。

未処理rejectionは、拒否した瞬間には通知しない

Promiseがrejectした瞬間にunhandledrejectionを発行すると、その直後や同じmicrotask checkpoint内でcatchを付ける通常のコードまで、未処理として報告してしまう。このPRでは、BoaのHostPromiseRejectionTrackerが送るRejectとHandleを記録する段階、checkpointで通知候補を取り出す段階、DOM manipulation taskでeventをdispatchする段階を分けた。

const promise = Promise.reject(123);
queueMicrotask(() => promise.catch(() => {}));

この場合の通知件数は0になる。rejection_handled_in_same_checkpoint_never_notifiesは、unhandledとhandledの双方を監視して確認する。未処理を一度報告してから打ち消すのではなく、候補だったrejectionを通知前に除く。

pending、queued、outstandingの三段階

JavaScript側のPromise reporterには、pendingのMap、通知taskが作られたqueuedのWeakSet、通知済みで未処理のoutstandingのWeakSetがある。pendingはPromiseとreasonを持ち、checkpointからの確定通知でtaskをqueueして空にする。

taskが動く前にも、Promiseがhandledになっていないか確認する。たとえばdetailsのtoggle taskが先にcatchを付ければ、すでに作られていたrejection通知taskは何も発行しない。details_dom_task_can_handle_rejection_before_notification_taskの記録はtoggleだけになる。checkpointを越えたから必ず報告する、という判定にはしていない。

taskではcancelableなPromiseRejectionEvent("unhandledrejection")を作る。eventに入るPromiseとreasonは元の値で、コピーされた別のobjectではない。通知の後、なおhandledでなければoutstandingへ入れる。その後にcatchが付いたときだけ、cancelableでないrejectionhandledのtaskをqueueする。

この「通知の後」が効くのは、unhandledrejectionのlistener自身でcatchを付ける場合だ。listenerの実行中はまだoutstandingへ入っておらず、dispatchから戻った時にはhandledなので入らない。結果はunhandledだけで、rejectionhandledは続かない。キャンセルとhandledも別だ。preventDefaultを呼んだだけではPromiseへcatchが付いたことにはならない。

run_jobsだけではeventが出ず、run_until_idleでunhandledが来る。後からcatchを付けるとhandledが来る。文書のreadinessとの順序もinteractive,rejection,complete,loadとassertする。

Handleを受けた時に、配列全体を探さない

この通知経路には、正しさだけでなく性能の問題もあった。マージまでの調査記録によると、途中の実装にはPromiseのHandle通知ごとに配列全体を走査する処理があり、CIの負荷でタイムアウトが表面化した。rejectionをn件積み、順にhandleする場合、毎回n件近い候補を探すと、合計の仕事は二乗に増える。

最終実装のPromiseReportsは、各reportへsequenceを付け、handledのPromiseをHashMap<JsObject, u64>で記録する。Handle時に保存するcutoffは、次に使うsequenceだ。checkpointでReject reportを読む時、そのreportのsequenceが同じPromiseのcutoffより小さければ通知を抑える。

sequence 0: Promise A のReject
sequence 1: Promise B のReject
            AのHandleを記録。cutoffは2
sequence 2: AのHandle report
sequence 3: Aの別のReject report

この説明用の並びなら、Aのsequence 0だけをcutoff 2で抑え、sequence 3は抑えない。後のreportまで消す「handled Promiseの集合」だけでは、記録された操作の順番を表せない。handled_cutoff_suppresses_only_earlier_rejectionsというunit testは、同じobjectの前後にReject reportを作り、この境界を直接assertする。

これは一つのPromiseが通常のJavaScriptで何度もsettleできるという説明ではない。通知記録という内部データ構造が、前の操作を理由に後の記録を過剰に消さないことを試すテストだ。HandleごとのHashMap更新は平均的にO(1)、checkpointの判定はreportを一度読むO(n)になる。メモリを一切使わなくしたのではなく、繰り返し全走査していた仕事をcheckpointへまとめた。

Handleで必要なJavaScript reporter呼び出しは、その操作の時点で行う。通知済みPromiseなら、後続のページ側のtaskより先にhandled通知taskをqueueするためだ。そのreportにはdeliveredを付け、checkpointから二度呼ばない。単純にすべてのHandle処理を次のcheckpointへ遅らせる最適化ではない。

checkpointへ移した値を、callback中のGCから守る

Rust側のflushは、std::mem::takeでpendingとcutoff mapをHostStateから取り出す。取り出した後は、HostStateのtraceからそれらのPromiseへ届かなくなる。それでもRustのVecには参照が残るが、BoaのGCにとって、それだけでrootになるとは限らない。

最初のreporter callbackが割り当てや強制GCを行うと、まだ通知していない二番目のPromiseやreporterが回収され得る。そこでPromiseCheckpointとRootProviderを用意し、Boxで安定したアドレスへ置いたcheckpointを、通知中の外部rootとして登録する。pendingのPromise、cutoff mapのキー、reporter callbackをtraceする。

guardはBoxより後に宣言するので、scope終了時はroot登録を解除してからBoxをdropする。callback実行中にはcheckpointの内容を読み、登録先のアドレスを動かさない。checkpoint_roots_later_promises_across_reporter_collectionは、二つのrejectionを作り、最初のreporter内でforce collectしてもfirst,secondが揃うことを確認する。

Realmの所有とDocumentの退役も追跡する

通知先のreporterはDocument identityごとにcallbackとRealmを持つ。子iframeのPromiseがrejectすれば子globalへ通知し、親globalへ寄せない。一方、親側でcatchを付けた場合でも、同じPromiseの未通知rejectionは抑えなければならない。cutoff mapがPromise identityをキーにするのは、この跨ぎを扱うためでもある。

Documentが退役したら、retire_documentでそのreporterとpendingを外す。ただし、退役するDocument側で付けたcatchが、別の有効なDocumentのrejectionを抑えていた場合、cutoffまで一律に消すと通知が復活する。残ったReject reportに効くcutoffだけを再構成し、不要なPromiseの保持をやめる。

Mapのset/delete/clear/forEachやiteratorをthrowする関数へ差し替えても、保存したintrinsicで通知処理は動く。通知候補の管理をページ側のproperty lookupへ委ねない。

hostの実行中断は、ページが投げた例外に変えない

BoaのRuntimeLimitはhostの実行中断であり、JavaScriptが投げた値とは違う。Error objectへ変えて通知すれば、中断したい状況でさらにページ側のerror listenerを実行してしまう。

dispatch_exception_reportは、エラーをopaque valueへ変換する前にruntime limitを判定して、そのまま返す。listenerのhandleEvent getterとcallback本体、microtask、Worker起動・taskの各入口でも同じ区別を保つ。event listener・microtask・Promiseの回帰テストでは、loop上限を16にして無限loopを止め、ページのerror件数とcatchの記録が0、次の独立した2 + 3が5になることを確認する。

Promise内部でも、throwをrejectionへ変換する前にcatchableかを見る。Promise reactionとthenableの処理でhost abortをrejectionへ変えると、後続のcatchが実行され、上限を突破した処理が通常の非同期失敗として続いてしまう。syncとasyncのjob executorで、thenのcallback、then getter、executor、Promise.try、各combinatorのiteratorを試し、ordinary throwなら元のsentinelでrejectすることも対照としてassertする。

abort時には、呼び出し後に実行するはずだったnative continuationも残してはいけない。初期のevaluation開始時の深さを覚え、終了時にその深さへtruncateする。前の失敗したcallbackのcontinuationが次の無関係なscriptで実行されると、エラー通知やwindow.eventの復元を違う処理へ適用してしまう。テストはabort後の独立した関数で古いcontinuationが動かず、新しく呼び出したbridgeでは正常に動くことを確認する。

macOSで出たPromise.finallyのSIGSEGV

このPRで時間がかかった理由には、性能とは別の不具合もある。macOSのRelease Gate 5で、Promise.finallyを通る実行がSIGSEGVになった。全体を速くすれば自然に消える種類の失敗ではなく、GCから見えない値を途中で使っていた。最終コメントでは、修正前の同じtest bytesで、Promiseがpendingのままになるassertionとsignal 11を再現したことが記録されている。

前のArray.from()の保持漏れや、getter返値の誤回収と同じく、Rustのローカルに値があるだけではBoaのGCのrootにならない。今回はfinally内の中間Promiseと、そこへ渡す関数が対象だった。

finallyは元の値をそのまま返すだけではない

まず、何を作っていたのかを確認する。説明用に次のコードを考える。

const original = { answer: 42 };
Promise.resolve(original)
  .finally(() => Promise.resolve())
  .then(value => console.log(value === original));

finallyのcallbackは後片付けのための処理で、元のPromiseがfulfilledになったときもrejectedになったときも呼ぶ。callbackが普通の値を返したり、fulfilledのPromiseを返したりした場合、後ろへ渡すのはその後片付けの返値ではなく、元の値または元の拒否理由になる。上の例なら、最後のthenが受け取るのはoriginalだ。

後片付けがthrowしたり、その返したPromiseがrejectしたりすれば、後ろの結果も変わる。そのためBoaの実装は、後片付けの完了を待つclosureをfulfilled用とrejected用に作る。

fulfilled側は、onFinallyを引数なしで呼んで返値resultを得る。その返値をconstructor CのPromiseResolveに渡す。次に、元のvalueを返すだけのvalueThunkを作り、中間Promiseのthenを取得して呼ぶ。rejected側も順番は同じで、最後の小さな関数が元のreasonをthrowするthrowerになる。

元のPromiseがsettleする
  → onFinallyを呼ぶ
  → PromiseResolve(C, callbackの返値)
  → 元のvalueを返す関数、または元のreasonをthrowする関数を作る
  → 中間Promiseのthenを取得する
  → 作った関数をthenへ渡す

thunkは、後片付けの完了後に元の値を返す関数だ。rejected側では元の拒否理由をthrowする。

中間Promiseを受け取った後の空白

修正前のfulfilled側では、PromiseResolveの返値をローカル変数promiseへ受け取り、その後にFunctionObjectBuilderでthunkを作っていた。このbuilderはGC管理の関数やcaptureを割り当てる。割り当てを伴うので、nurseryの閾値を越えればminor collectionが起こり得る。

PromiseResolveが中間Promiseを返す
  → Rustのpromiseというローカルへ受け取る
  → thunkを作るために割り当てる
     → GCが起こり得る
  → promise.thenを使う

中間Promiseがどこからも強く保持されておらず、このローカルのedgeだけが残っているなら、GCはそれを生きている値として認識できない。そのまま回収すると、次にthenを読むときには解放済みのPromiseへ触ることになる。GCがその瞬間に来なければ普通に通るため、通常の成功だけでは見つけにくい。

変更した部分では、PromiseResolveから受け取る時点で明示的にrootした。fulfilledとrejectedの両側で同じように行う。

let promise = Self::promise_resolve(&captures.c, result, context)?.root();

このrootはthunk生成とthen取得・呼び出しまでを覆い、scope終了で外れる。GCを停止する変更ではなく、内部処理中のPromiseの生存を明示する。

thenを読む間にもユーザーコードは走る

中間Promiseだけをrootしても、まだ一つ空白がある。thunkを作り終えた後、その関数をJsValueへ変換して引数に置き、中間Promiseへinvoke("then", ...)する区間だ。

thenは常に固定の組み込み関数とは限らない。Promiseのインスタンスへ、独自のthen getterを定義できる。invokeはまずプロパティを取得し、その値をcallableとして呼ぶ。getterが走れば、そのgetterからGCを誘発することもできる。取得した関数を呼ぶ前に、引数のthunkがGCから見えなくなっていたら、そこで回収される余地がある。

修正はvalue_thunk.into()やthrower.into()でrootを持つ値そのものを消費する形を避け、rootを保持したまま、その中のJsObjectのcloneを引数に渡す形にした。fulfilled側とrejected側の呼び出しは次の形になっている。

promise.invoke(
    js_string!("then"),
    &[JsObject::clone(&*value_thunk).into()],
    context,
)

ここで残すcloneはGCのedgeを引数へ渡しつつ、rootを持つthunkをcall終了まで保つためのものだ。巨大なデータの複製とは目的が違う。

GCを置く位置を二つに分けたテスト

追加した回帰テストは、単にfinallyへたくさんPromiseを流す負荷試験ではない。中間Promiseが消え得る位置と、thunkが消え得る位置を別々に狙っている。さらに、それぞれでPromise.resolveとPromise.rejectを使う。二つの境界に対してfulfilledとrejectedの両経路、合計四つの条件を確認する構成だ。

一つ目のfinally_intermediate_promise_survives_thunk_allocation_collectionでは、native callback cleanupReturningPromiseを登録する。このcallbackは最初にcollectionを行い、その後でテスト対象の中間Promiseを作る。続いてNoGcScopeの中で、4,096 byteのpaddingを持つ使い捨て割り当てを1,025個作る。payloadだけで通常の4 MiB nursery閾値を越える。

NoGcScope中にはcollectionを実行させない。scopeから出ても、そのdrop自体でcollectionを始めるわけではなく、実行可能な状態へ戻る。callbackは作ったPromiseを返し、PromiseResolveのidentity経路は同じconstructorのPromiseをそのまま返すためGC割り当てを行わない。したがって、次の割り当てはfinallyのthunkを作るところになる。

paddingにはfinalizerのcounterも付いている。テストはcounterが1,025以上になったことを確認するので、「たまたまGCが起きずに成功した」という結果を成功条件に含めない。その後、cleanup callbackの呼び出しが一度であること、最終結果がfulfilled:42またはrejected:42であることを確認する。元の値はオブジェクトで、=== originalによってidentityも検査する。同じ内容の別オブジェクトを返しただけでは通らない。

二つ目のfinally_thunk_survives_collection_in_then_getterでは、callbackが返す中間Promiseに独自のthen getterを定義する。そのgetterでnativeのforceCollectを呼び、元のPromise.prototype.thenを返す。thunkを作り終えた後、invokeがthenを取得する瞬間にGCを入れるテストになる。

こちらはGC回数が一回であること、cleanupとgetterがそれぞれ一回呼ばれたことを確認してから、元の値または拒否理由のidentityを調べる。元のPromiseがfulfilledだったのにrejectedになる、拒否理由が入れ替わる、callbackを二度呼ぶ、という意味上の不具合も一緒に検出できる。単にクラッシュしなかったことだけを期待値にしていない。

LLDBにも、起動したprocessを明示する必要があった

macOSの診断を取る補助も変更している。scripts/gate5_lldb_capture.pyは、記録したWPT executableをLLDBから起動し、停止時点の全threadのstackやregister、loaded imageを保存する。

LLDBの外側のscript commandは、起動前のexecution contextを保持する可能性がある。起動後に返されたprocessからlldb.SBExecutionContext(process)を作り、HandleCommandへ渡して、診断commandの対象を揃えた。

補助はbinary SHA-256、argv、cwd、launch flags、target triple、pid、状態を記録し、binaryが変わっていないことも確認する。これは別processでの診断であり、元のGate 5失敗を成功へ置き換える実行ではない。

moduleをlinkした後、ASTをいつまで持つか

古いDocumentやRealmをJavaScriptが保持すること自体は必要だ。メモリを減らすために、その世代を勝手な上限で消すことはできない。今回は世代に付随するデータの寿命を見直した。

今回見直したものの一つが、Boaのsource text moduleのASTだった。bootstrapをmoduleとして実行するため、各Realmで大きなmoduleをparseし、linkし、evaluateする。そのmodule recordが残る間、構文木までずっと残す必要があるかを検討した。ModuleCodeの変更では、sourceを常に存在するboa_ast::Moduleから、RefCell<Option<boa_ast::Module>>へ変えている。

元のsource textと構文木は役割が違う

ASTはparseで作る構文木で、link時のenvironment生成やcompileに必要だ。link後の実行にはcompile済みcodeとenvironmentを使う。一方、Function.prototype.toString()や例外位置のためにsource textは残す。今回捨てるのはtreeであり、source_text、import/export table、実行codeは保持する。

一つのmoduleのcompile終了で捨てるのは早い

難しいのは、ASTをなくす時点だ。module Aがmodule Bをimportし、BもAをimportする循環があると、単純な「一つのmoduleのenvironmentを作った直後」を成功の境界にできない。

// a.js
import { missing } from "b.js";
export function answer() { return 42; }

// b.js
import { answer } from "a.js";
export function present() { return answer(); }

この入力では、Bのenvironmentを先に準備できても、Aのmissing importが解決できない。循環する組全体のlinkが成功したことにはならない。Bだけ見て「compile済みだからASTを捨てる」とすると、その後でAが失敗し、組全体をUnlinkedへ戻したとき、Bが次の試行に必要なtreeを失ってしまう。

実装では、循環module群の強連結成分、SCCがcommitされる境界でtreeを取り出してdropする。linkのcommit部分に、未成功のSCCは再試行のためASTを保持するというコメントがある。PreLinkedは準備が済んだ段階であり、組全体の成功が確定したLinkedと同じではない。

link失敗時には、stackのLinkingとPreLinkedをUnlinkedへ戻す。個々のmoduleを準備できたことと、SCC全体のlink成功を区別して解放する。

link失敗の後に、同じ失敗をもう一度返せるか

循環link失敗の回帰テストは、上のA/Bに相当する入力をMapModuleLoaderへ登録する。load自体はfulfilledになる。ファイルが取れない失敗ではなく、linkで存在しないexportをimportしようとする失敗だ。

一度目のa.linkはSyntaxErrorになり、messageにmissingが含まれることを確認する。その後でminor GCとmajor GCを強制し、もう一度linkする。期待値は、最初と同じSyntaxError messageだ。ASTを先に捨てていた場合は、二度目の試行でcompileに必要なtreeがなく、同じ意味のエラーを返す前にpanicする余地がある。

独立したdependencyが先にcommitできた場合には、そのASTを捨ててよい。失敗した親moduleのtreeは残し、次のlinkで同じエラーを返す。既にcommitしたdependencyの再linkは、捨てたtreeを要求し直さない。

treeを捨てても残る観測を検査する

成功後の回帰テスト群では、まずparse後にtreeがあること、link後にtreeがないことを内部状態で確認する。そのうえでcollectionを挟み、evaluateしてnamespaceをJavaScriptのglobalへ登録する。loaderの登録やRust側のmodule変数を外した後にも、exportした関数や値が使えるかを調べる。

入力には、module scopeのcountを更新するbump(delta = 1)、import.metaを返すmeta、同じdependencyをdynamic importするload、例外をthrowするfail、\uD800を含む文字列、top-level awaitが入っている。bumpは連続呼び出しで41、42になり、closureがmoduleのbindingを保持していることを確認する。nameはbump、default parameterのためlengthは0、toString()は元のfunctionの文字列と一致する。

import.metaは二回呼んで同じオブジェクトであり、そこへ書いたmarkerも残る。dynamic importは同じnamespaceを返し、dependencyの値が42である。unpaired surrogateはcharCodeAt(0) === 0xD800を維持する。fail()がthrowしたErrorには、設定したlinked-source.jsというfilenameと5行目という位置が残る。

成功する循環moduleのテストでは、Aがexportしたmutable bindingをB経由で読み、Aの関数で更新した後にBの読みが変化することも確認している。これはexport値を一度コピーしておけばよいわけではなく、live bindingがcompile済みenvironmentから正しく参照され続けることを確かめる。ASTをなくした後でも、42から43への変化を観測できる必要がある。

loadに失敗してまだdependencyがないケースでは、ASTを保持したままloaderへdependencyを追加して再loadし、成功したlinkの後でtreeを捨てる。linkの再試行だけでなく、load段階の失敗から先へ進む入力もある。treeの寿命を短くする変更は、「どこなら捨てられるか」と同時に「どこではまだ捨てられないか」を書き下す作業になっている。

minor GCで、どのephemeronを調べるか

背景はWeakMapと世代別GCで説明した。キーが到達可能なときだけ値を保持するephemeronには、値の走査で別のキーが生存すると分かる条件があり、固定点まで処理する必要がある。

minor GCはnurseryの若い割り当てを対象にし、古いキーは保守的に扱う。若いキーについては、今回のmarkで到達可能になったかを見る。

既存のmark_minorは、root、RootProvider、remembered set、WeakMap registryをseedにしてstrong queueと未解決のephemeronを処理する。この骨格は変更前から維持している。今回は、そのqueueへ投入する候補を狭めた。

()を値に持つ古いephemeron

WeakMapの値は任意のgraphを持ち得る。一方、weak handleの生存確認に使うephemeronには、値が()のものがある。キーを弱く追跡したいだけで、条件付きで保持するpayloadに子edgeがないケースだ。

そこで追加したneeds_minor_traceは、ephemeron自身が若い場合、または値の型が()ではない場合に、従来どおりminor処理へ回す。ephemeron自身が古く、値の型が厳密に()なら、キーの世代も読む。古いキーを指している場合には、nurseryへ新しいedgeを露出させるものがないので、minorの候補にしない。

ephemeron自身がyoung           → 処理する
valueの型が()ではない          → 処理する
old ephemeron + () + young key → 処理する
old ephemeron + () + old key   → 今回のminorでは省ける

ここで省くのはminor collectionのqueue処理だ。Tracerのephemeron queueで、modeがMinorでありneeds_minor_traceがfalseならcontinueする。古いallocationはmajor heapへ残っている。majorでキーの生死を判断する機会までなくしたわけではない。

old ephemeronでもyoung keyへretargetされたらminor処理が必要だ。valueがgraphを持つ場合や、ephemeron自身がyoungの場合も省かない。

0 byteの型だからtrace不要、とはしない

判定に使っているのはsize_of::<V>() == 0ではなく、TypeId::of::<V>() == TypeId::of::<()>()だ。Rustのzero-sized typeには、独自のTrace実装を持たせられる。サイズが0であることと、trace時に何もしないことは同じではない。

old_non_unit_zero_sized_ephemeron_preserves_its_custom_traceは、サイズ0のZeroSizedTraceを値に入れる。Traceが呼ばれるとthread-localのcounterを増やす。キーとephemeronをoldへpromoteした後にminorを行い、counterが増えることを確認する。()と同じサイズだからとスキップしていたら、このテストは通らない。

ほかにも、世代の組み合わせの回帰テストがある。old keyとold unit ephemeronはminor後にもupgradeできるが、keyのrootを外してmajorを行えばupgradeできなくなる。minorで省いたからといって、weak handleがキーをmajorのrootへ昇格させてはいけない。

young unit ephemeronとold keyの組では、minorを何度か行った後にもephemeron allocationが残り、keyを読めることを確認する。keyを強く保持するrootを外しmajorを行うと消える。old unit ephemeronをdead young keyへretargetした場合には、minorでupgrade不能にし、その後live young keyへ再retargetすると再び使えることを確認する。古いallocationのheaderだけ見て、一度除外した候補を永久に除外する設計にはしていない。

finalizerの後は、最初の候補を使い回せない

さらに気になるのが、collection中のfinalizerによるretargetだ。最初のmarkでold keyを指すold unit ephemeronだと分かっても、その後のfinalizerがyoung keyへ付け替えることがある。最初に省いたときの条件が、second markまで同じとは限らない。

追加したsecond markのテストでは、まずoldなkey、parent、weak handleを作る。次にyoungなtargetと、そのtargetが保持するyoung valueを作り、到達不能なfinalizerに渡す。finalizerはweak handleをtargetへretargetする。条件によっては、parentにもtargetへのstrong edgeを書いて復活させる。

復活させる条件では、second markが新しいyoung keyとそのvalueを見つけて保持する必要がある。復活させない条件では、retargetしたweak edgeだけを理由にyoung keyを保持してはいけない。この二つを同じhelperで実行している。weak handleへの書き込みと、strong edgeへの書き込みを分け、前者だけで生き残ることを禁止する。

pointerを読む前に、collectorのallocation集合にtargetとvalueが存在するか検査する。生存条件ではその後にupgradeして99を読み、回収条件ではpointerをたどらずupgrade不能を確認する。

復活させたtargetについても、後でparentのedgeを外してmajorを行えば、targetとvalueは消える。second markで救った値を永久rootにしたわけではない。minorの一回の途中で強参照が追加されたため保持し、その参照がなくなった後には通常の回収へ戻す。

WeakMapの存在確認でvalueをcloneしない

WeakMapの最適化には、より小さく見える変更もある。Rust側のcontains_keyは以前get(k).is_some()で実装していた。getはvalueを取り出すAPIなので、戻り値のためにcloneする。存在するかどうかだけが必要な呼び出しでも、valueのcloneが走っていた。

変更後のcontains_keyは、keyからhashを作り、table内の同じkeyを探して、ephemeronにvalueがあるかだけを確認する。keyの比較はobject identityに基づく。valueを取り出さないので、存在確認にvalueのcloneは必要ない。

expired entryのcleanupも、eph.value().is_some()からEphemeronEdge::has_valueへ変更した。cleanupはvalueの中身を使う処理ではない。keyがなくなってentryが無効になったかを見てtableから取り除くだけなので、所有されたvalueを用意する理由がない。GC中のregistryの生存確認でも、weak handleをupgradeして所有handleを作る代わりに、is_upgradable()で可否だけを見る。WeakMap registry側の変更も同じ考え方だ。

clone回数の回帰テストは、PresenceCloneSpyというvalueを入れる。Cloneが呼ばれるとatomic counterを増やす。元のkeyと同じkeyのaliasでは存在し、同じ数値7を持つ別keyでは存在しないことを確認した後、clone回数が0であることを見る。

元のkeyをdropしてcollectionしても、aliasがrootされていればentryは残り、存在確認はcloneしない。実際にgetでvalueを取り出したときは一回cloneする。今回の変更はgetの所有された戻り値までなくしたのではなく、contains_keyの意味に不要なcloneだけをなくしたことが、counterに現れる。

cleanupのテストもlive keyとdead keyを入れ、dead側をdropしてminorを行う。entryは一つへ減り、clone回数は0のままだ。live側をgetしたときだけ一回増え、その後のmajor cleanupでも増えない。live keyを外すとentryは空になり、map本体も外してcollectionすればregistryから消える。速くするためにdead entryを残す変更ではないことまで調べている。

oldになったWeakMapへyoung entryを追加する

古いunit ephemeronを省くとき、WeakMap registryのtrackerとの組み合わせも気になる。registryはhost側の一覧で、普通のstrong heap edgeと同じ入口ではない。そのためmark_minorはlive WeakMapのbacking ephemeronを明示的にseedする。

trackerの回帰テストでは、mapと最初のkeyをoldへpromoteした後、新しいyoung keyとyoung valueを追加する。そのminorでyoung valueが残り、getが42を返し、oldなentryも11を返すことを確認する。tracker自体がoldになったという理由で、新しいentryを見落としてはいけない。

young keyをdropして次のminorを行うと、そのvalueは回収されentry数は一つへ減る。old keyをdropしてmajorを行うと、oldなvalueも消えmapは空になる。mapをdropしてmajorを行うと、WeakMap registryもなくなる。registryが保持していたunit ephemeronのrootをsweep後に外すため、そのtracker allocation自体は次のmajorで回収するところまで確認する。

DOM bootstrapの文字列をRealmごとに作り直さない

GCで仕事を減らす前に、そもそも同じ大きな入力を何度も作っていたところもある。OmoikaneのDOM wrapperやevent interfaceは、Rustのhost bindingとJavaScriptのbootstrapを組み合わせて初期化する。このPRの調査記録では、約1.3 MBのDOM bootstrapを各Realmで変換し、コピーしていたと報告されている。

各Realmのconstructorやglobalは独立して初期化する。一方、同じbinding集合から生成するbootstrap sourceは同じなので、文字列だけを共有できる。

private bindingのため、元の文字列をそのまま使えない

host capabilityはpage globalへ公開せず、trustedなbootstrap moduleへimport.meta.__omoikane_private_bindingsで渡す。先頭でdestructuringし、そのprivate propertyを削除する。

既存のJavaScriptファイルには、globalThis.__omoikane_...の形でhost関数を読む記述もある。そのままでは、privateなlexical bindingへ移したhost関数を読めない。そこで文字列を組み立てる処理は、登録されたhost binding名に一致する記述を、module内のlexical binding名へ書き換える。

登録済みのglobalThis.__omoikane_...をlexical binding名へ置き換え、古いdelete文はvoid 0にする。未登録の名前は書き換えない。

名前の件数やRealmの種類だけをkeyにしない

cacheの型は、OnceLock<Mutex<HashMap<Vec<String>, Arc<String>>>>だ。keyはbindingの完全な順序付きの名前列になる。

static BOOTSTRAP_MODULE_SOURCES:
    OnceLock<Mutex<HashMap<Vec<String>, Arc<String>>>> = OnceLock::new();

ここは、「main用」「iframe用」の二つだけを用意すればよいというcacheではない。main、iframe、auxiliaryのbindingに違いがあり、今後subsystemがbindingを追加する可能性もある。件数だけをkeyにすると、同じ数で名前が違う集合を取り違える。集合の一部だけをhashへ渡すような省略も、必要な名前を持たないsourceを再利用する原因になる。

BootstrapBindings::valueは名前を重複登録しない。同じ名前の値を更新しても、sourceのheaderへ名前を二重に出さない。再利用時は巨大なStringではなくArcをcloneする。

ただし、これはcompiled moduleやAST、Realmの共有ではない。evaluate_dom_bootstrapは、得たsource bytesをそのcontextでparseし、load、link、evaluateする。各Realmのenvironmentとconstructorを作る仕事は残る。source生成・書き換えと大きな文字列のコピーを省く変更として読む必要がある。

source builderの二重bufferも減らす

cacheに入れる最初のsourceを作る処理にも変更がある。変更前はheaderをsourceへ書き、共通bodyの書き換え結果を別のbodyというStringへ貯め、最後にbody全体をsourceへ追加していた。cache missごとに、変換後の大きな本文を別bufferからもう一度コピーする形だ。

変更後はheaderを作ったsourceへ、そのまま書き換え後のbodyを追記する。source.reserve(DOM_BOOTSTRAP.len())で本文の容量を先に確保し、未変換区間とbinding名を順に追加する。巨大なbodyを完成させてから、もう一つのStringへ丸ごと移す段階をなくした。

一方、古いdelete文をvoid 0へ置き換えるためにsuffixを見るときは、headerの部分を見てはいけない。そこでbody_startを保持し、source[body_start..]がdelete で終わっているかを見る。bufferをまとめる変更で、以前bodyだけを対象にしていた処理の範囲まで広げない。コピーを一つ減らしただけでも、元の処理がどの範囲を見ていたかは合わせる必要がある。

並列初期化でも、完成前のsourceを返さない

複数のruntimeが並列にRealmを作る場合、同じkeyのsourceを同時に要求することがある。OnceLockはcache本体を一度初期化し、Mutexはmapのlookup、必要ならsourceをbuildする処理、insertを同じlockの中で行う。最初のthreadがまだsourceを組み立てている間に、ほかのthreadが未完成のStringを取り出す状態にはならない。

PRの最終コメントには並列初期化の検証も記録されている。cacheが共有するのはsourceであり、各Realmのparseや初期化は残る。

evaluate後はbinding名がpage globalのown propertyになっていないことを検査する。また、一時的にloaderへ登録したmodule recordとhost stateのbinding objectを外す。source cacheは各Documentのbinding objectを保持しない。

履歴のURLを読むだけで、子Realmを作っていた

sourceを共有しても、不要なRealmを作る経路が残っていたら、parse、compile、constructor生成の仕事は続く。その一つがiframeの履歴snapshotだった。WindowProxyを初めて作るときや、古いDocumentから移る前に履歴を保存するとき、必要なのはcommit済みのURLと履歴の情報だ。ところが、そのURLを読むためにDocument wrapperを作り、そのwrapperの所属先として子Realmのbootstrapまで実行していた。

wrapperが必要な操作と、native stateだけで足りる操作

native DOM、URL、originはscriptlessな子にも必要だが、JavaScriptのconstructorやglobalを直ちに作る必要はない。

contentWindow.closedやLocationのhrefはnative stateから答えられる。これだけのために子のDateやDocument constructorをbootstrapすると、すぐ捨てる子にも大きな初期化が走る。

変更後のcaptureHistoryEntryは、まず既にあるwrapperをcachedNodeで探す。same-originで、そのwrapperのevent stateとprivate履歴URLが存在するならそれを使う。なければnativeのcommit済みDocument URLを使う。新しいwrapperを作るwrapNodeを、URL取得のためだけには呼ばない。

private履歴URLを先に見るのは、pushStateやreplaceStateで変わったURLを保存するためだ。元の取得先URLだけを使うと、同じDocumentのまま移った履歴のURLを失う。一方、authorに見せているdocument.URLのgetterを直接呼ぶと、そのpropertyをauthorが置き換えている場合に余計なコードが走る。内部snapshotに必要な値は、privateな履歴stateかnative stateから取り出す。

public URL getterを読まないテストでは、childDocument.URLへgetterを定義する。そのgetterはcounterを増やしてthrowする。contentWindowを得て履歴を記録しても、counterは0のままになる。最適化でgetterを偶然呼ばなくなっただけでなく、内部snapshotがauthor getterへ依存しないことを期待値にしている。

lazyなまま進むことを内部状態でも確かめる

iframe_history_snapshot_does_not_initialize_a_scriptless_realmは、contentWindowとclosedを読んだ後、host stateのiframe_documentsを見て、全entryのrealmがNoneであることを確認する。Locationを読んだ後にも同じ確認をする。

その後でLocationへdata URLを代入し、さらにsrcdocを変更する。古いscriptlessな子を退役させる準備や、新しい履歴generationのsnapshotでも、departing Realmを作らないことを確認する。ここで値として答えが合っているだけでは、最適化が効いているか分からない。realm.is_none()という内部状態も見ることで、同じ観測をより少ない初期化で得ていることを直接調べる。

最後にchild.documentへ明示的にアクセスする。これはDocument wrapperと、そのprototypeを提供する子Realmが必要な操作なので、そこでRealmを作る。Object.getPrototypeOf(child.document) === child.Document.prototype、child.document.defaultView === childを確認し、host stateのRealmもSomeになっていることを見る。lazy化は必要なものまで欠落させる変更ではない。

opaque sandboxの子については、URLや履歴をnative側で保持できても、親へDocumentを見せてよいわけではない。opaqueな子のテストでは、contentWindow.closedを読めてcontentDocumentはnullであり、Realmはまだ作られていない。child.documentを読もうとするとSecurityErrorになる。lazyであることと、origin boundaryをなくすことを分けている。

既存Realmを探す処理は、初期化する処理から分ける

native側にも、生成しないlookupが必要になった。iframe_global_nativeは、二つ目の引数がfalseなら既存のiframe DocumentとRealmだけを参照する。新しいloadやensure_iframe_realmを始めない。同じようにexisting_realm_for_documentは、resource eventで既存の所有Realmがあるかを見るための経路だ。

また、既存Document lookupにもsame-origin限定の引数を用意している。破棄準備で子孫をたどるとき、すでにloadされた子だけを見て、そのうち親からアクセス可能なものだけwrapperにする。cleanupのために新しい通信を始めたり、cross-originのDocumentを親Realmへ包んで渡したりしない。

WindowProxyのidentityと、退役したWindowのidentity

navigation中はWindowProxyのidentityを保ち、Documentとbacking Windowのgenerationを変える。detachではcontextを閉じ、reconnectでは新しいcontextとproxyを作る。

liveなproxyで古いWindowを強く抱え続けない

getActiveWindowは、Realmがある場合、そのglobalをIntrinsicWeakRefで持つ。次のproperty readでそのweak referenceが生きていれば使い、消えていれば現在のnative stateから取り直す。navigationのgenerationが変わったときには、新しいbacking Windowのstateへ切り替える。stableなproxyを保持することと、過去の全Windowを強く保持することを同じにしない。

detach前には最後のWindowをretained側で強く保持し、same-originの保存済みproxyから読めるようにする。live proxyによる弱い参照と、退役後の保持を分ける。

detachとreconnectのテストでは、初めにDocument、WindowProxy、custom element registry、localStorage、sessionStorage、Historyを保存する。iframeを外した後、elementのcontentDocumentはnullで、保存したproxyはclosedになる。そのproxyのdocumentは元のDocumentと同じで、registry、Storage、Historyのobject identityも保存したものと一致する。

ただし、古いHistoryが同じオブジェクトだから、操作まで有効ということではない。oldHistory.lengthはSecurityErrorになる。History APIはfully activeなDocumentに対する操作を要求し、読む操作にもその制約がある。nativeのactive検査は、length、state、scroll restoration、その更新、push/replace state、goを対象にする。object identityの保持と、browsing contextの操作権を分けている。

再接続すると、elementから取るWindowProxyは昔のproxyとは別で、Documentも別になる。新しいproxyはclosedではなく、history lengthは新しいcontextの1になる。昔のproxyはclosedのままで、昔のDocumentを保持する。古いDocumentのdefaultViewはnullになる。このテストでは、同じDOM elementを再利用することを、browsing contextの再利用とみなしていない。

listener用のplaceholderをWindowと取り違えない

scriptlessなiframeのRealmを作らずにaddEventListenerできるようにするため、proxyにはlistener listだけを持つplaceholderもある。このobjectは親側で作った補助stateで、子Realmの実際のWindow globalではない。

placeholderはcreator側のobjectであり子Windowではない。これを転送先にすると、親Realmを子Realmと誤認し、opaque originの拒否まで壊す可能性がある。

新しいretained_window_global_nativeは、渡されたobjectのassociated Realmへ一時的に入り、そのRealmのactual global objectを取る。渡されたobjectとglobalが同じidentityであることを確認する。listener placeholderなら、この検査に通らない。

さらにRealmのhost-defined dataからimmutableなDocument idを取り、caller Realmへ戻した後にsame-originを検査する。子Realmに入ったままorigin判定をすると、検査するcallerまで子にすり替わってしまう。WindowProxyのtrapから呼ぶ検査でも、authorの実際のcallerを保持することが必要だ。

Realmは作成時のorigin snapshotも保持する。native metadataが回収された後に関数やwrapperがRealmを保持しても、現在のDocumentのoriginを借りたり、metadata欠落をsame-originと解釈したりしない。

listenerは必要な時点で実際のWindowへ移す

Realmを作る前にproxyへ登録したlistenerは、Realmを作った後には実際のWindowでイベントを受け取る必要がある。getActiveWindowのlistener transferは、placeholderのtypeごとのentryをlive Windowのlistener listへ移す。

それまでproxyへ登録できたことと、その後のchild scriptがglobalへ登録するlistenerが同じ対象へ集まることを両立する。すでにRealmがあるなら、departing pageのpagehideをdispatchする前にgetActiveWindow(false)で同期する。ないなら、そのイベント通知のためだけに子Realmをbootstrapしない。

departureのfallbackも、新しくDocumentをwrapするのではなく既存wrapperを探す。実際のRealmがある場合はnative側からそのRealmでlifecycle eventをdispatchする。scriptlessな子でwrapperだけある場合は、そのwrapperに対して通知できる。どちらもないなら、通知するために大きなglobalを作り、すぐ捨てる経路へ入らない。

内部のWeakMap lookupにはcapture済みのintrinsic methodを使い、authorがprototypeを置き換えても別コードを呼ばない。既存stateの参照と生成も分けている。

lazyなWindowを保持してから外すテスト

退役したlazy Windowの回帰テストは、通常のsame-origin子、allow-same-originのsandbox子、opaque sandbox子を用意する。最初はWindowProxyだけ保存し、全てのRealmがまだNoneであることを確認する。

その後iframeを外し、same-originの二つについて、保存したWindowからDocumentを取り、古いnodeやtextを保持する。nodeにmarkerを付け、idleまで進め、collectionを二回行った後に、古いDocument、親node、text、expandoが変わらず使えることを確認する。iframe elementのcontentWindowとcontentDocumentはnull、保存したWindowはclosedでも、保持したDOMの関係は残る。

opaqueな三つ目では、closedになってもdocumentはSecurityErrorで拒否する。detachでorigin boundaryを消してはいけない。同じようにRealm intrinsicsのテストは、data URLへのnavigationをcommitした後、子のDateを読めないこと、detach後も読めないことを確認する。一方、same-originの時点で先に取得したoldDate constructorは、後でもnew oldDate(123).getTime()が123になる。

adoptしたnodeのownerDocumentが変わっても、wrapperのcreation Realmは変わらない。回帰テストは、新ownerとmarkerを確認しつつ、constructor経由で古いDocumentを取れることも確認する。

回収検査はmicrotask checkpoint後に行う。pendingなPromise reactionもRealmのrootなので、checkpoint前に残ることをleakと扱わない。active Documentのnode保持は別の残課題だ。

cross-origin WindowProxyのthenと二つのwell-known symbolの限定fallbackは、Issue #1326に残る。このPRで仕様差を全て解消したわけではない。

pending navigationを読むたびにfetchしていた

iframeに関するもう一つの大きな変更は、現在activeなDocumentと、これからcommitするnavigationを分けたことだ。変更前は、WindowProxyの状態をrefreshするときに、現在のsrcに対応したDocumentを取得し直す経路へ入っていた。属性が変わっていれば、その読み取りが同期的なfetchとparseを始める。

次のように、同じscriptの中でURLを何度も書き換える場合がある。

const child = frame.contentWindow;
child.location.href = "href.html";
frame.src = "/frame/assign-base.html";
child.location.assign("assign.html");
frame.src = "/frame/replace-base.html";
child.location.replace("replace.html");
frame.src = "/frame/final.html";

Locationの表示とDocumentのcommitを分ける

変更後のnative state lookupは、既存のiframe entryがあるなら、そのactive Documentを使う。queued resource navigationがあるという理由だけで、新しいsrcをそこから取得しない。初期Documentがまだなければ作るが、一度active Documentができた後は、resource taskまたは明示的なcontentDocument読み取りがcommitを行う。

native stateにはaccess、context id、generationに加えpending-resourceを返し、現在のDocumentとqueued taskを分ける。

JavaScript側はpendingLocationURLを別に保持する。hrefは最新の保留URLを返すが、nativeのactive DocumentとURLはcommitまで変えない。

明示的なcontentDocument読み取りは必要なら同期commitする。今回省いたのは、proxyの状態確認やnavigation準備による途中のcommitだ。

六回書き換えても、取得は初期と最後だけ

superseded_iframe_location_navigations_fetch_only_the_committed_resourceは、local HTTP serverで実際に受けたpathを記録する。最初のiframeは/frame/child.htmlを指し、親Documentは/caller/parent.htmlにある。test scriptはchild Documentを一度読んでRealmを作り、既存Realmがある経路を通したうえで、六回URLを書き換える。

scriptの途中ではLocationを何度も読む。そこで返るのは、親のcaller baseから解決したhrefやassign/replace先、直接frame.srcへ設定したframe側のURL、最後のfinal URLだ。Locationが古いURLを返すようになったら、fetchが減っていても成功ではない。

script終了直後のhost stateでは、Document URLは初期の/frame/child.htmlであり、HTTP requestもその一回だけである。run_until_idle()でresource taskを進めた後、active Documentは別identityになり、URLは/frame/final.htmlへ変わる。request一覧は初期とfinalの二つだけになる。

7回から2回という記録は、この同一script内の六回の書き換えと初期URLのケースだ。途中でcontentDocumentを読む場合や、別taskでcommitする場合まで二回へまとめる変更ではない。

activeなURLへ戻ると、generationは変わらない

保留中のnavigationが最後にactiveなresourceへ戻るケースもある。テストはfinal Documentをcommitした後、同じscriptで一度/frame/discarded.htmlへ変え、続けて/frame/final.htmlへ戻す。resource taskはすでにqueuedだが、実行したときには取得対象が現在のDocumentと同じになる。

ここでは新しいDocumentを作らず、requestも増やさない。だからgenerationの変更を待ってpending stateをclearするだけでは足りない。新しいgenerationが来ないまま、pendingHistoryActionやpendingLocationURLが残ってしまう。

WindowProxyのrefreshは、generationが変わっていなくても、pending history actionがあり、native側のpending-resourceがfalseになったなら、保留中のhistory actionとLocation URLをclearする。queued taskがno-opとして完了したことも、保留状態を解消する境界にしている。

その後、同じDocumentへ#settledを付けるfragment navigationを行う。これはDocumentを取り直す必要がない。テストはDocument identityがfinalのままで、native URLだけがfragment付きになり、request数が二つから増えないことを確認する。保留状態が残っていたら、fragmentだけの変更を新しいresource navigationだと扱う余地があるため、no-op直後の操作まで検査する。

別taskのnavigationは履歴へ残す

同じscript内の途中のnavigationをまとめても、実際にcommitした別taskのDocumentをまとめて消してはいけない。direct src navigationの履歴テストは、/a.htmlを設定しtaskを進め、次に/b.htmlを設定してtaskを進める。間にWindowProxyのpropertyを読まなくても、initial、A、Bの三つのentryを記録し、backでAへ戻れることを確認する。

reloadやtraverseは、現在のresource属性が同じでもDocumentを入れ替える必要がある。そのためnative側にはforce navigationの集合がある。resource属性の同値判定だけで、reload()やhistory.go(0)をno-opへしてはいけない。context idは保ち、WindowProxyは同じまま、backing DocumentとWindow stateを新しくする。

同じDocumentに属する複数のpushState entryを、traverseで復元した新しいgenerationへ付け替える処理も残る。state entryの復元テストでは、Aのstate-one、state-twoを作ってBへ移り、backでAの世代を復元する。その後隣のsame-document entryへ戻っても、Document identityは変わらずstateが1になる。

selected entryだけを新generationにすると、隣のsame-document entryを再loadしてしまう。同じ旧generationのentry全体を付け替える。

LocationとHistoryで、相対URLの基準が違う

親が/caller/parent.html、子が/frame/child.htmlなら、親からのLocation navigationはcallerをbaseにし、子のHistory state URLは対象Documentをbaseにする。

iframe_location_navigation_resolves_relative_to_caller_documentは、この違いを一つの入力に入れている。Locationのhref、assign、replaceは/caller/へ向かい、Historyのstate URLは/frame/になる。pending状態へ分けるときに、最後にactiveなURLを全部の操作のbaseへしてしまうと、この期待を壊す。

about:blankやabout:srcdocではcreatorのbaseを使う。nested Documentはownerのeffective baseを使い、無関係なtop-level URLを借りない。

親を捨てる準備で、捨てる子を取得しない

親iframeのDocumentには、さらに子iframeがある。子でnavigationがpendingなまま、親が別のDocumentへ移ると、その子のbrowsing contextは破棄する。ここで破棄準備の再帰探索が子のcontentDocumentを読むと、キャンセルするためにこれから捨てるresourceを取得してしまう。

discarding_parent_iframe_does_not_fetch_a_pending_child_navigationは、outerとinnerに別のlocal HTTP serverを用意し、request一覧を同じvectorへ記録する。outerのinitial文書にはinner iframeがあり、innerのinitial文書をloadして、そのRealm内でlocation.href = '/inner/pending.html'を実行する。

この時点では、requestはouter initialとinner initialの二つだけだ。次にouterのsrcをfinalへ変え、明示的にouterのcontentDocumentを読んでcommitする。期待する一覧は、その二つにouter finalを足した三つだけになる。inner pendingは取得しない。

修正後の破棄準備は、既存のDocumentだけをnative lookupで探し、アクセス可能なら既存wrapperを使う。native teardownはpendingなresource taskや、Document所有のtimer、animation frameなどをキャンセルする。子孫を調べるために新しいloadへ入らないことが、キャンセルの前提になる。

resource属性とHTTP bodyのコピーも減らす

navigationの大きな無駄を外した後には、同値判定のためだけのコピーも残っていた。srcdocが有効ならその文字列、そうでなければsrc、objectならdataを読む。変更前は、判定するたびに属性mapを取得し、必要な値をcloneして比較していた。

resource loadのschedule処理は、tagをborrowしてiframeか、srcdocを受け付けるかを判定し、effective attributeを決める。すでにloadしたentryがあれば、with_attributeのborrowedな文字列をloaded_resourceと比較する。srcdocは内容をそのまま比較し、srcはtrimした値を比較する。

比較だけならborrowで足りる。実際にnavigationをcommitしentryへloaded resourceを保存するときには所有データを用意する。

HTTP response bodyも同様だ。HttpResponse::into_bodyを追加し、responseを消費して所有しているVec<u8>を返せるようにした。iframe resourceの取得では、effective URL、Content-Type、CSP headerを先に取り出し、その後responseを消費してbodyを移す。借用したbody()から新しいvectorを作る段階を省いている。

一件を読むために、一覧を複製していた

ここからは、通常経路の性能修正をもう少し細かく見る。GCやiframeの章でもコピーと保持に触れたが、HTMLのテキスト追記には、それらとは別に、入力が増えるほど仕事が膨らむ経路があった。

HTML parserは、隣り合う文字をできるだけ同じText nodeへ追記する。helloの一文字目ごとにnodeを五つ作るのではなく、挿入位置の直前にTextがあればそこへ追加する。そのために必要なのは、挿入先の「直前の一件」である。通常の末尾挿入なら最後の子でよい。

修正前は、この確認にchild_nodes()を使っていた。変更前のtree builderでは、親の全childrenをownedの一覧へ取り出してから、最後やreferenceの直前を選んでいた。nodeの中身を深くコピーしているわけではないが、一覧の全handleをcloneする。子が多ければ、Text一件を選ぶだけで兄弟数ぶんの処理が走る。

説明用に、毎回一件ずつ子が増え、毎回全件を見るモデルを置く。

1回目: 0件を見る
2回目: 1件を見る
3回目: 2件を見る
...
N回目: N-1件を見る

合計 = 0 + 1 + ... + (N-1) = N(N-1)/2

この式は実際のparser全体の命令数ではない。HTMLでは一つの入力片から複数nodeが増える場合もあり、ほかの探索もある。ただ、「一件必要だから全一覧を作る」を成長する一覧へ繰り返すと、入力に対して二次の仕事を作るという点は同じである。Nを倍にすると、この部分だけなら仕事はほぼ四倍になる。

修正後のinsert_textは、通常の末尾ならparent.last_child()を使う。native DOMのlast_childは、借用したchildrenの末尾だけを参照して、そのhandleを返す。一件のhandleを持ち出す処理は残るが、全件のsnapshotは作らない。

document.writeの境界とtableの外へ出す文字

通常末尾だけなら話は簡単だが、parserの挿入位置はいつも末尾ではない。document.writeには生きた挿入境界がある。table内の不適切な文字をtableの外へ移すfoster parentingも、tableの直前を見る必要がある。

insert_textでは、write boundaryのreferenceが現在の親に属しているかを確かめ、そのreferenceがあればprevious_sibling()を読む。referenceがなければlast_child()を読む。この分岐で、入力片が分かれても同じ挿入位置のText nodeを使い続ける。boundaryが古くなり、referenceが別の親へ移っている場合には、それを無条件に使わない。

foster_parent_textも、tableのprevious_sibling()だけを見る。Textならそこへ追記し、そうでなければ新しいTextをtableの前へ入れる。HTMLの木構築では、ソースに書いた位置とDOMに入る位置が一致しない場合がある。コピーを消すために単純な末尾appendへ変えたら、その木構築規則を壊してしまう。

ここで「隣接siblingがO(1)になった」とは書けない。現行のadjacent_siblingは、親のchildrenを借用し、その中の自分の位置を探している。全handleのcloneはしないが、位置を見つける走査は残る。末尾参照と、任意のreferenceの隣を探す処理は、同じ変更で同じ計算量になったわけではない。

この違いはIssue #1330へ残した。固定referenceの前へ大量に挿入するdocument.writeやfoster parentingでは、毎回の位置探索が成長するsiblingsをたどり、総走査が再び二次になり得る。PR #1322で除去したのは、通常の末尾テキスト挿入にあった明確な全一覧コピーである。限定された境界経路の線形探索を全部解決した、という話ではない。

512件の回帰テストが確認するもの

追加したordinary_text_insertion_does_not_snapshot_existing_siblingsは、<body>の後ろにx<br>を512回置く。Textと要素が交互に増えるので、既存siblingsを毎回複製する実装なら、普通の連続Text一件だけの入力より問題が見えやすい。

テストはnative DOMのtest-only counterをリセットしてからparseし、children snapshotがcloneしたhandle数を読む。その数にITEMS * 16未満という上限を置き、最後にbodyの子がITEMS * 2、つまり1024件であることを確認する。

このassertionは「parser全体でhandle cloneが一回もない」ではない。木の構築にはほかのsnapshotやhandle取得もあるため、合計に余裕を持った上限を置いている。また、counterを読むのは最後のchild_nodes()による結果確認より前である。結果の子一覧を検査するためのsnapshotを、parse中の不要コピーとして数えない順番になっている。

UTF-16の正確な値と、表示する文字列を分ける

もう一つの二次コピーはTextの中身にあった。JavaScriptの文字列はUTF-16 code unitを保持できる。high surrogateだけ、low surrogateだけという、Unicode scalar valueとしては表せない値も作れる。RustのStringは有効なUTF-8なので、その値を完全には表せない。

現行のTextは、表示やscalar処理に使うdata: Stringに加え、必要な場合のexact UTF-16 bufferと、unpaired surrogateの数を持つ。通常の有効な文字列には、常に二種類の全文bufferを作るわけではない。exact unitsが必要になった時にその経路へ入る。このデータ構造はTextとset_data_utf16で確認できる。

全文を作り直す追記の問題

変更前の追記は、unpaired surrogateを含むTextに文字を増やすたびに、蓄積済みの内容も含めてexact unitsを取り出し、追記後の全文をdecodeし直す形になっていた。Textの長さが増えるほど、一回の追記で触る既存prefixも増える。短い入力片をN回入れると、先ほどのsiblingsと同じ三角形の仕事になる。

現在の全文を取り出す
  → suffixを加える
  → 追記後の全文をdecodeする
  → dataとexact unitsを更新する

修正後のappend_text_utf16は、suffixだけをdecodeする。既存Textがexact bufferを持っていれば、そのbufferをcloneせず取り出し、suffixを追加して戻す。持っていなければ、最初に必要になった時だけscalar prefixをUTF-16へ昇格する。その後は既存prefixを毎回encodeし直さない。

通常のscalar suffixなら、data.push_str()で表示側へ追記し、exact bufferがある場合だけそこにもsuffixを増やす。single surrogate一件の場合には、一般的なdecode結果のbufferを作る経路も避ける。何が正確に保持されるかを変えるのではなく、すでに保持しているprefixをもう一度作る仕事を減らした。

VecやStringは、容量が足りなければ再確保することがある。したがって一回の追記が常に厳密な定数時間だとは言えない。ここでいう償却線形は、蓄積済み全文を毎回明示的に再構築する経路を除き、bufferの追記として扱えるようにしたという意味である。

入力片の境目でsurrogate pairが完成する

UTF-16では、一つのscalar valueをhigh/low surrogateの二code unitで表す場合がある。highだけを先に受け取り、次の入力片がlowで始まることもある。入力片ごとに独立した文字列としてdecodeして、その結果をただ連結すると、二つの置換文字になってしまう。

説明用に、0xd83dの後へ0xde00が来る場合を考える。最初の段階ではexact bufferはhigh一件で、表示側には置換文字がある。次にlowを追記すると、exact bufferの末尾highとsuffix先頭lowがpairになる。最終的な表示側は😀、exact unitsは[0xd83d, 0xde00]になる。

1片目
  exact:  D83D
  scalar: 置換文字

2片目の先頭が DE00
  exact:  D83D DE00
  scalar: 😀

実装は、既存exact buffer末尾のhighと、suffix先頭のlowを調べる。pairができたら、表示側の末尾の置換文字を外し、その二unitからdecodeしたscalarを入れる。suffix側の先頭lowが作っていた置換文字も重複して追加しない。既存prefix全体をdecodeし直すのではなく、結合する境界だけを直す。

unpaired数の更新も境界に合わせる。既存のhigh一件とsuffixのlow一件は、pairの完成によって二つともunpairedではなくなる。それ以外のsuffix内のunpairedは残る。単に「suffixの不正文字数を足す」だけでは、この状態がずれる。保持しているcountを、表示とexact unitsの両方に一致させる必要がある。

pairが完成してもexact bufferをすぐ捨てない

一見すると、unpaired数がゼロになればexact bufferは不要に見える。scalar側からUTF-16へ戻せるので、memoryを減らすために捨てたくなる。しかしそれを毎回行うと、次のhigh一件が来た時に、増えたscalar全文をもう一度UTF-16へencodeすることになる。

high、low、high、lowと細かく交互に届く入力を考える。pairが完成するたびにbufferを捨て、次のhighで全文昇格すると、最終値は全部有効なUnicodeでも、prefixの再encodeを何度も繰り返す。修正したはずの二次コピーが、入力の境目の取り方だけで復活する。

そのため現行実装は、後の追記でpairが完成しても、いったん確保したexact bufferを保持する。コメントにも、このbufferを落とすと次のlone surrogateで全文再encodeが復活すると書いてある。「いまの内容をscalarだけで表せるか」と、「次の追記のために保持するbufferが有用か」は別の判断である。

もちろん、buffer保持にはmemoryの費用がある。最初から有効なscalar入力だけだったTextとは違い、一度この経路へ入ったTextは、完成後にもexact unitsを持つ場合がある。今回の変更はその費用と、繰り返し全文を変換する費用の交換である。最終的に全部のTextが常に二重保持されるという話でも、exact bufferが常にゼロになるという話でもない。

Nと2Nで、時計ではなくcopy-workを見る

回帰テストは二つの入力を分ける。一つは、parserへhigh surrogateだけを256件、512件と渡す場合。もう一つは、Textへhighとlowを別々に繰り返し追記し、256pair、512pairを作る場合である。parser側のテストとsplit pair側のテストで、それぞれ結果の値とcopy-workを確認する。

どちらも入力を倍にした時に、counterの仕事量が三倍未満であることをassertする。線形なら理想的にはほぼ二倍、毎回全文を作り直す二次の経路ならほぼ四倍になる。三倍という境界は、細部の固定費を許しながら、今回の失敗形を区別するためのものだ。

このcounterは、OSが観測する全memory trafficでも、全allocation数でもない。test-onlyで、対象のUTF-16コピーやbuffer昇格、owned取得などへ加算する指標である。最後にexact unitsを取得する検査も、その経路のcopy-workとして入る。Vecの内部再確保や、tokenizer上流の全処理まで一つの数で完全に表しているわけではない。

native nodeを一件取得する経路も見直した

parserの末端だけでなく、JavaScriptからnative DOMを読む入口にも不要コピーがあった。firstChildやlastChildで一件欲しいのに、全IDを集めた一覧やJavaScript Arrayを作る経路、IDを比較するだけなのに属性Stringをownedで取得する経路がある。同じ値を返していても、問い合わせの粒度と内部で作るものの粒度が合っていなかった。

変更では必要なnodeだけをnative側から読むようにした。ただし、native nodeがすでに木へ入っていても、JavaScript wrapperがまだ作られていない場合がある。一件だけ読む経路でも、そのnodeを発見して適切なDocumentへ登録し、同じnodeを読むたびに同じwrapperを返す必要がある。

この意味を確認するのがnode_copy_tests.rsである。firstChildからnative挿入したTextを読む、lastChildから要素を読む、隣のnodeを読む、といった経路で、dataだけでなくparentNodeとownerDocumentも検査する。コピーを減らしたgetterが、未登録wrapperを取りこぼしたり、別文書のwrapperとして作ったりしないことを見ている。

さらに、親の公開childNodesへthrowするgetterを置いた例もある。nativeの関係を調べる処理がauthorのchildNodesを呼んでしまうと、内部の一件取得がページの任意コードへ変わる。この回帰テストは、関係getterがその公開overrideへ依存しないことを確認する。getterを一回減らしたという性能の話だけでなく、内部処理がどのauthor codeを実行するかの話でもある。

ID検索で保つもの

ID検索は、対象の属性値が一致するかを調べるだけなら、毎回Stringをcloneする必要はない。ただし、値を借用して比較するようにしても、検索契約は維持する必要がある。

テストは同じIDを持つ二nodeを置き、木順で先のnodeが返ること、そのnodeをremoveした後は残りが返ること、再び前へinsertすると元のnodeが返ることを調べる。また、name.with[selector] #punctuationのようなIDを使い、CSS selectorへ変換した結果で検索していないことを確認する。空文字や大小文字も別の条件として扱う。

textContentで集めてよい木

子孫の文字を集める処理も、一覧や中間UTF-16 bufferを大量に作りやすい。ここでは文字を直接追加する経路へ寄せたが、「すべての見える文字を連結する」ではDOMのtextContentにならない。

テストでは、普通のText、Comment、ProcessingInstruction、nested element、shadow tree、template content、XMLのCDATAを混ぜている。fragmentのtextContentへ入るのは対象の通常treeのTextで、CommentやPIのdataは混ざらない。hostのlight treeを読む時にshadow treeの文字を勝手に取り込まない。template contentも別のtreeなので、hostから一般の子孫として歩いてはいけない。

また、異なるTextにhighとlowが分かれている場合も、結果をJavaScript文字列として連結すれば、そのcode unitsは正確に並ぶ必要がある。各Textの表示用scalarだけを集めると、元のunitが置換文字へ変わり得る。コピーを減らすために使うread経路でも、exact unitsを扱う契約は残る。

このためnative DOMにはwith_character_dataのような短い借用の入口を用意している。callbackが走る間だけscalarと必要なexact unitsを参照できる。この入口ではnodeを不変borrowしているため、callbackからDOM mutationやauthor codeを呼ばないという制約が付く。借用を長寿命のHostStateへ持ち回る設計ではない。

Fontの内部通知が、公開のMutationRecordを必要としていなかった

コピー調査ではFontの保守処理も見つかった。文書内のstyleやlink、文字、関連属性が変わると、使用するフォントやCSSの@font-faceを見直す必要がある。変更前は、そのために内部のMutationObserverを置いていた。

しかしこのobserverのcallbackは、届いたMutationRecordを使っていない。変更されたnodeの全一覧や前後のsibling、oldValueを一件ずつ読んで処理するのではなく、現在の文書をもとにsyncCSSとrequestUsedFontsを実行する。その用途なら、「変化があったのでcheckpointで保守を行う」という通知で足りる。公開observer用のrecordを内部observerにも作ることは、利用しない情報を組み立てる仕事になっていた。

変更後のfont_loading.jsは、privateのmutation hookを登録する。通知から受け取った現在のDocumentを使い、FontFaceSetを同期する。公開のMutationObserverのrecord、oldValue、callback順序を削って代用したわけではない。内部の用途が必要としていないrecordを、その内部用途のためだけに作る経路を外した。

この変更の途中では、文書の寿命に回帰が出た。Issue #1167の候補検証には、コピー削減の局所比較が改善していても、既存の文書寿命テストが失敗したことが残っている。旧binaryはisolatedで成功し、候補binaryはisolatedで失敗した。良い性能値が出たから、残る失敗を既知問題へまとめて進めたわけではない。

切り分けると、すでに退役したiframeのDocumentへFont maintenanceが走り、内部CSS例外がPromise rejectionの通知taskに入る経路だった。新しい例外通知があるため、内部保守の失敗が通知queueを通じて別の保持を作る。性能調査と通知機能が、文書寿命の場所でつながった形である。候補5の記録で、この回帰の修正と既存契約の再確認を報告している。

現行callbackは、保守を始める前にprivate native bindingのis-liveを読む。そのpredicateはcallbackのRealmに結び付いたimmutable ownerを基準にする。作者が渡すDocumentらしい値を見て決めたり、現在activeな別Realmの状態を借りたりしない。退役後なら、payloadを作る前、resource/style処理へ入る前に戻る。

FontFace.loadedのPromiseを、必要になった時に公開する

FontFaceの状態Promiseにも変更が入った。変更前はFontFaceのconstructorでPromiseを作っていた。sourceが不正で読み込み状態がerrorになると、ページがそのPromiseを一度も取得していなくてもrejectが発生する。未処理rejectionの通知を実装すると、それまで隠れていた内部のPromise生成がページのイベントとして見えるようになる。

現行のfontStatusPromiseは、loadedを取得するかload()を呼ぶ時にPromiseを作る。すでにloadedならresolveし、すでにerrorなら保存していたerrorでrejectする。状態の失敗を消して成功へ変えるのではない。公開するPromiseの生成時点を、その値を要求した時点へ移している。

一度作ったPromiseはstateへ保存する。face.loaded === face.loadedやface.loaded === face.load()が保たれるので、読むたびに別のPromiseを作る実装にはならない。処理がすでに終わった後でも、同じPromiseに対してhandlerを付けられる。

invalid_font_promise_is_exposed_lazily_and_keeps_its_rejectionは、不正な文字列sourceと不正なbinary sourceからFontFaceを作る。idleまで進めると両方のstatusはerrorだが、まだunhandledrejectionは来ない。その後、一方のloadedを読むと、同じPromiseのidentityを保ったままSyntaxErrorのrejectionが観測される。さらにcatchを付けると、そのerrorを読めることを確認する。

局所計測の数字を、対象ごとに読む

ここに載せる値はPR #1322の局所計測の報告値である。同じext4、build flagsを使った別processの比較であり、同じprocessへ差分を次々適用した値ではない。old-unit ephemeronの比較はB-A-A-B、module AST保持削減は別の比較元を使っている。これらの改善率を足して、全体の改善率にしてはいけない。

対象と指標変更前変更後報告された減少率
old-unit ephemeron、80世代保持のwall中央値46.471秒34.585秒25.58%
同じ比較のuser CPU中央値44.079秒33.892秒23.11%
同じ比較の最大RSS中央値2,395,764 KiB2,309,724 KiB3.59%
同じ比較の固定点処理18.000秒6.395秒64.47%
module AST保持削減、80世代保持の最大RSS中央値4.70 GiB2.27 GiB51.72%
同じAST比較のwall中央値32.72秒31.72秒3.05%
iframe location遷移の最大RSS約510.15 MiB約289.28 MiB43.30%
同じiframe比較のwall1.5825秒1.4388秒9.08%

wall、user CPU、最大RSSは違うものを見ている

wallはprocessの実経過時間である。user CPUはユーザー空間でCPUを使った時間で、待ちやほかのprocessとの競合を含むwallとは同じにならない。最大RSSは、そのprocessで観測されたresident memoryの最大値であり、終了時に残っていたmemoryでも、allocation総量でもない。

AST保持を減らした比較では、最大RSSが約半分になった一方、wallの減少は約3%だった。この二つは矛盾しない。長く保持していた構造を解放できればpeak memoryは下がるが、その構造を保持していた時間の全部でCPUを使っていたわけではない。削った保持量が大きくても、実行時間の支配項がほかにあればwallの変化は小さい。

逆に固定点処理は、全体wallより大きく短縮している。その処理だけを見ると64.47%減でも、全体では25.58%減である。対象処理の外にはparse、compile、DOM、ほかのGCや初期化もある。局所の改善率を全体へそのまま使うと、この差を無視することになる。

B-A-A-Bが抑えるものと、抑えきれないもの

B-A-A-Bのように実行順を交ぜるのは、時間が経つにつれてCPUの状態やbackground負荷が変わる影響を、一方のrevisionだけへ集めないためである。前半を全部変更前、後半を全部変更後にすると、単に後半のmachineが冷えた、またはwarmになったことが、差分の効果に見える場合がある。

Acid3の5秒制限は、ローカル成功では片付かなかった

PRの検証で長く残ったのがAcid3 test26のwall-clock制限である。ARM64のinterpreterやbaseline JIT、x86_64のbaseline JITなどで、5秒へ到達した。別にiframeの相対URL遷移テストもtimeoutした。機能のassertionが間違っているのか、正しい処理が予算内に収まらないのかを、ログと入力から分ける必要があった。

10月9日未明のコメントでは、ARM64 interpreterのFaithfulは100点なのにDirectがtest26で止まること、比較元mainの同じARM64 interpreter jobはDirect 3408ms、Faithful 2915msで成功していたことを記録した。この時点では、現在のPR headのrequired checksが失敗している。ローカルで一回100点が出ても、その事実を成功へ書き換えることはできない。

最初に見直したのはDOM挿入の祖先検査の重複だった。完全なpre-insert検証を終えたappend/insertの通常経路で、同じ祖先走査をもう一度行っていた。検証をなくすのでなく、済んだ検証を再実行しない経路へ分ける。これは前のDOM章で説明した、公開の例外と内部の信頼境界を保つ変更である。

当時のローカル一回ずつの比較はDirect 3079→2783ms、Faithful 2277→2129msで、両方100点だった。数字は改善の手掛かりになるが、反復もenvironmentも限定されている。コメントにもCI回復の証明にはしていないと残した。一回の差を、CIの負荷の差へそのまま換算してはいない。

その後、履歴URLの読み取りで子Realmを作らない変更や、HTMLの属性名判定でnative DocumentのMIME状態を読む変更を入れた。10月9日夕方の報告では、追加した属性判定、履歴、node lifetimeのfocused testsは成功し、Acid3はFaithful 1751.59ms、Direct 2296.34msだった。ただしその時点では未commit、未pushの候補で、現在PRに載っている失敗を解消済みとは報告していない。

そして更新後も、x86_64 baseline JITのBrowser jobは5秒制限で失敗した。Faithfulは100点でもtest26が4799.61ms、Directは上限に到達してscore26で終わった。そのコメントに、MutationRecord、queueMutation、insertNodeBeforeInternalの処理中に中断したことが残っている。

8processの並列再現で、iframeの通常経路を調べた

相対iframe URL遷移のtimeoutは、同じtestを8process並列で実行して再現した。報告では、変更前は8件中1件失敗し、所要時間は5.15〜5.59秒だった。先行差分の後は4batch、32件すべて成功し、3.65〜4.09秒、平均3.92秒になった。比較の条件と結果はPR本文に載せている。

一processの単独成功と、この実験は確認している条件が違う。並列にすることでCPUやmemoryの圧力が増え、単独では目立たない余分な初期化や保持が、制限に近づく。CIそのものと完全に同じ環境を作ったわけではないが、問題になった処理を同じ入力で、競合のある条件へ置く材料になった。

さらに今回、同一script内で6回連続してiframeのURLを書き換える経路を調べた。修正前は初期URLを含めて7回同期fetchが走り、そのうち2回は取得した後で破棄されていた。WindowProxyの状態参照が、保留中resourceをその場でfetch/parseしていたためである。

修正後は、resource taskで最終世代をcommitするまでactive Documentを保つ。途中のURL書き換えをまとめ、初期URLと最終URLの2回だけ取得する。この7→2は、そのfixtureで観測したfetch件数である。wallの改善率ではない。network、parse、Realm生成のどれが何%を占めたかを、この件数だけから決めることもできない。

コピー量の割合を、CPU時間の割合にしない

Issue #1167のコピー調査には、DHATのcopy記録もある。runtime、bootstrap、前段を含む691,074,813Bで、Acid3 test26のtimeoutによって中断したsampleだった。Boa parser/compilerが約74.1%、DOM helperが約1.38%という内訳も記録した。

これを「test26のCPU時間の74.1%がBoa parser/compiler」とは扱わない。測った量はコピーbyteである。コピー一byteの費用も、対象memoryの状態や呼び出しの固定費によって変わる。copy以外の計算、allocation、GC、待ち時間は、この割合の分母に入っていない。しかもtimeoutで完走していないsampleなので、処理全体の総量としては使えない。

別の固定128反復sampleでは、DOM関連memcpy/memmove量が254,918→124,372B、copy callが23,197→3,113へ減ったと報告した。通常は使わないsaved origin cloneの削減が主な要因だった。このsampleも、bootstrapを含む全runtimeのcopy量やCPU割合とは分けて読む。候補1の同条件比較に、改善値と同時に文書寿命の失敗を記録している。

後の候補5では、元fixture、5秒制限、通常release/default feature、Cargoを止めた条件、別processのB-A-A-Bで、4回ともDirect/Faithful100点だった。test26平均はFaithful 1710.01→1465.41ms、Direct 2328.34→1915.89msだった。ただし各revision二回の局所計測であり、この時点で全体test、build、新CIはまだ完了していなかった。記録にもその限界を残している。

固定WPTと現行仕様が食い違った一件

WPTでも一件、実装の誤りだけでは説明できない不一致があった。固定revisionのMutationObserver-characterData.htmlに含まれる、ProcessingInstructionのdata mutationである。Issue #1323に、fixtureのhash、上流履歴、失敗したsubtest名、仕様の確認先を残した。

入力は<?processing data?>である。古いHTMLの扱いなら、これをbogus commentとして読み、dataに?processing data?が入る。今回実装対象にしたprocessing instructionの扱いでは、targetがprocessing、dataがdataのProcessingInstructionになる。後からCharacterDataを変更した時のMutationRecordのoldValueは、その解析結果を反映するため一致しない。

現在のHTML Standardのtokenizerにはprocessing instructionのstateがある。記事の執筆時にもその節を確認した。一方、固定WPT revisionはdc97e7bed3096ac9e0e591ab5fa22e7fb8844eadで、Issueの調査では該当上流ファイルに2014年の期待が残っていた。最新仕様と、固定した古い入力・期待値が、同じ時点の内容とは限らない。

対応ではfixtureを編集していない。期待値を新しい値へ差し替えたローカルコピーだけを走らせ、固定WPTがPASSになったと数えることもしない。元の入力とassertionを残し、manifestのknown failureで、characterData ProcessingInstruction: data mutationsという一つのsubtest名を明示した。

known failureをケース全体の白紙許可にしない

manifest.jsonの登録には、status、理由、Issue、期限、failed subtestsを持たせている。今回の期限は2026年11月8日で、追跡先は#1323である。期限を書いたから自動で仕様差が解決するわけではないが、いつまでも理由のない例外として残さないための追跡情報になる。

分類するclassify_with_subtestsは、FAILというstatusだけでknown failureにしない。結果のsubtestを読み、失敗名を集めて、登録している失敗名の集合と一致するかを調べる。結果が配列でない、失敗名が欠ける、想定外statusがある場合も回帰へ分類する。

したがって同じWPTファイルの別subtestが落ちれば、今回登録した一件とは違う。timeoutやscript errorも、FAILとして登録した仕様差の範囲へは入らない。全部PASSになった場合にはimprovementとして分かる。成功へ向かった時にも、known failureの登録をそのまま置き続けないための材料になる。

この分類処理自体は、今回のPRで一から作ったものではない。既存の、subtestまで限定して追跡する仕組みを使った変更である。新しいPI対応を確認する直接回帰と、固定WPTに残る旧期待の一件を、別々の検証として置いている。

PR本文に載せた先行headの固定WPTは、通常420ケース中415 PASS、既知の失敗5、回帰0だった。これを「420ケース全部PASS」とは書かない。また、先行headで得た詳細なcase数と、最終headのremote checksが成功したことも分ける。最終headに対するWPT、compatibility、Releaseの成功はGitHubのcheck結果で確認している。

最終headの検証は、focused testsだけで終わらせなかった

最終報告では、最終local head7ad562bfのclean targetでCI=1 cargo test --locked --no-fail-fastを実行し、111群、3,871 passed、0 failed、15 ignoredだった。cargo build --lockedも成功し、書式検査とdiff checkも通った。この記事でそのテストを再実行したのではなく、headを明示した当時の検証結果として記載している。

一方、15 ignoredは実行して成功した15件ではない。通常のlocal全体コマンドではignoredのまま残る。Gate 5のsuiteはjit-gate5.pyで--include-ignoredを含む別のコマンドを使う。local全体とremote Gate 5の集計は、同じ実行条件として足し合わせるものではない。

テスト件数の粒度を揃える

focused検証として、DOM unit 31、HTML tree builder 42、Promise report unit 4、p1_dom_modern83、p1_error_reporting67が全件成功したとPR本文に残している。ただし、この83や67は、ファイルの名前にある主題だけをそれぞれ独立に数えた機能数ではない。選択範囲や文書所有、Windowのidentityなど、隣接する契約も含まれる。

Rustの一つのtest関数が、JavaScript内で複数条件をassertする場合もある。WPTにはさらにsubtestの粒度がある。CI checkは、一つのjobが多数のテストを実行する単位である。「3,871 tests」「420 WPT cases」「63 checks」は、同じ単位の数字ではない。

63成功、2skipの中に何があるか

執筆時にGitHubから取得したPRのstatus checksは63 SUCCESS、2 SKIPPED、失敗0だった。最終CI、Browser behavior、Gate 5、CodeQLなどが含まれる。

二つのskipは、Release workflow内の配布公開と、PRでは別workflowで実行するnative JIT matrixの重複回避だった。release.ymlの条件で区別できる。Gate 5のsuiteやpackageが成功したことと、新しいブラウザversionを利用者へ公開したことは別である。このPRのマージを、そのまま配布リリース済みという意味にはしない。

Gate 5はnative実行とpackageの中身まで確認する

OmoikaneのGate 5の文書では、実際のdistribution archiveをbuildし、対応するnative hostで中身を実行する。cross-buildが緑というだけでは、そのCPUとOSで動くことは分からない。特にJITのABI、code memory、GC境界は、compile成功とは別の実行条件である。

対象はx86_64 Linux、ARM64 Linux、ARM64 macOSである。歴史的なIntel macOSの計測を、今回の配布support matrixの成功として数えない。suiteとpackageは別jobで走り、共通の解決済みCargo.lockを使う。aggregateは、それぞれ必要な結果があることを確認する。

最終runのsuiteは、x86_64 Linuxが35分22秒、ARM64 Linuxが27分22秒、ARM64 macOSが32分18秒だった。これは完了jobのstarted/completed時刻からも確認した。PR全体の作業時間でも、CI workflow全体のelapsedでもなく、それぞれsuite jobの所要時間である。

デバッガで成功しても、元のGateを成功へ変えない

MacのSIGSEGVを調べるために加えたdiagnose-gate5-wpt.pyは、元の失敗を残したまま、記録したbinaryをLLDBで実行する。通常suiteのresultがfailedであることを確認し、signal 11を記録したWPT executableに限定している。

source HEAD、binary hash、LLDB version、通常ログのhash、通常実行のenvironmentを保存する。debugger用WPT reportは別の出力先にする。元のreportをdebugger実行の結果で上書きしない。最後にもnormal filesとbinaryが変わっていないことを確認する。

補助script自身の回帰テストはtest_diagnose_gate5_wpt.pyにある。異なるcommandやtarget外のbinaryを勝手に診断対象にしないこと、元artifactを変更しないことなどを固定する。故障を調べる仕組みも、その仕組みが元の故障記録を消さないように検証する必要がある。

CodeQLが成功しても、抽出できないmacroは残っている

最終のCodeQL checksは成功した。ただしRust extractorの既知macro警告22位置は、Issue #1310で追跡を続ける。workflowの終了status、open alertの件数、対象sourceをどこまで抽出できたかは、別の情報である。

macroの展開が一部欠けると、その場所のASTや型、dataflowを十分に解析できない場合がある。警告を抑えてworkflowを緑にするだけでは、解析対象が回復したことにはならない。逆に、あるtargetでは無効なcfg内の式について、別targetの実行不具合まで断定することもできない。#1310ではplatformと位置を分け、最小再現例とDB比較を保存している。

まだ残るUTF-16のstaging

Text追記の末端を直しても、HTML入力からJavaScriptのDOMStringへ届く全経路のコピーがなくなったわけではない。Issue #1328では、通常のscalar入力にもUTF-16のowned中間bufferが複数回できることを整理した。

現在のtokenizerの&str入力は、いったんUTF-16 VecへencodeしてInputDecoderへ渡す。tokenのtext、comment、attributeも、UTF-16 bufferからStringへ戻した後、exact unitsの保持用に再びVecへする経路がある。JavaScriptからcreateTextNodeやCharacterData、attribute、document.writeへ渡す通常のJsStringにも、owned UTF-16 Vecを作ってnative側でStringへdecodeする経路が残る。

これらは通常は線形であり、今回直した「蓄積prefixを毎回全文コピーする」二次の問題とは違う。ただ、HTMLが大きければ、同じpayloadの複数表現が同時に残り、peak memoryやcopy量を増やす。二次の増え方を除いた後にも、一件あたり何度bufferを作るかは調べる価値がある。

検討している方向は、scalar入力を直接受け取れるdecoderの入口と、exact UTF-16が必要な値の経路を分けることだ。validなowned Stringはmoveし、unpairedを含む場合だけexact unitsを保持する値型も候補になる。ただし、これはIssueに置いた方針であり、PR #1322で全部実装した内容ではない。

serializerのsnapshotは、出力とは別のpeakを作る

Issue #1329は、HTML/XML serializerのowned payload snapshotである。最終出力文字列を作るために、入力DOMのTextや属性をowned UTF-16 Vecとして取り出すと、DOMのpayload、snapshot、出力が同時に生きる。大きいsubtreeほど、その余分な全量bufferがpeakを押し上げる。

現行のgetHTMLやXMLSerializerが正しい値を返すことと、出力以外の一時保持を最小にすることは別である。今回のserializer追加では、namespace、属性順序、escape、shadow/template、exact unitsを扱う必要があった。そこで作ったowned snapshotは、安全に観測を固定する方法ではあるが、author codeへ再入しない区間ならborrowした値からwriterへ直接書ける候補もある。

この候補でも、全量Vecを全量Stringへ置き換えるだけなら、同じpeakを別の表現へ移しただけになる。最終writerへ直接escape/encodeできるか、属性recordのうち必要な情報だけを保持できるかを調べる。短いborrowの間にauthor callbackやDOM mutationを呼ばないことも、API境界の条件にする。

detached nodeの保持は、単純なWeak化では片付かない

Issue #1167の調査コメントでは、active DocumentへaとTextを128回追加して削除し、JSの強参照を残さず、Promise/observerの処理待ちを解消し、GCを二回行っても、256/256のWeakRefが生きていた。登録node数は4→260→260→260だった。

この結果は、切り離したnode/wrapperが保持されている再現である。Acid3の時間の何%を占めるかは測っていない。保持があること、memoryが多いこと、test26がtimeoutすることを並べただけで、その保持がtimeoutの主原因だと断定しない。

構造としてはDocument/Realm group、DocumentNodes、HostState.nodesなどの保持を見ている。削除済みnodeをすべてWeakへ変えればよいようにも見えるが、nodeは木から外れた後もページに保持されて使える。ownerDocument、親子関係、同じwrapper、expando、listenerの契約を壊してはいけない。

今回修正した、退役iframeのDocumentを正しく保持する契約とも単純な逆ではない。生かすべき古いDocumentと、到達不能になったdetached componentを回収することは、両方必要である。PR #1322の最終検証が成功したことを、この既知保持が解消したことにはしない。#1167はAcid3の性能課題から始まったIssueだが、その後のコメントでこのDOM保持調査も追っている。

cross-origin fallbackとECMAScript機能は別の作業として残す

Issue #1326には、cross-origin WindowProxyの限定fallbackが残る。then、Symbol.hasInstance、Symbol.isConcatSpreadableの三keyは、現在のOmoikaneでSecurityErrorとなる経路があり、undefinedのdescriptorを返す条件へ追従する必要がある。一般の禁止propertyを全部undefinedにする変更ではない。

Issue #1295のIntl、Temporal、Iterator helpersなども、このPRの成果へ含めない。Boa側にfeatureとしてあるものと、Omoikaneのruntimeから使えるものは別であり、feature有効化でbinary sizeや起動、Test262、JIT契約へ影響する場合もある。DOMや例外通知が増えたことで、ECMAScriptの未対応機能まで自動的に埋まるわけではない。

最終的には元のtimeout、fixture、assertionを維持し、必要なchecksが通ったheadを994f17ccでマージした。残っている問題も、そのままIssueに置いてある。次に触る時は、今回守ったidentity、exact units、checkpoint、ownerの境界を壊さずに、そこからもう一段不要な仕事を減らしていく。