自作ブラウザの Omoikane に、Pointer Lockとフォーム関連カスタム要素を追加した。

前回はFullscreen APIと文字コード変換ストリームの話を書いた。今回はマウス入力をページへ渡す経路と、自作の入力部品をHTMLのフォームへ参加させる処理。

どちらも入口のAPIだけならそこまで大きくない。ただ、実際につなぐと、X11で入力が二重に届いたり、iframeのコールバックが別の実行環境で動いたり、フォームを使っていないページまで遅くなったりした。最後にはmacOSのテストでプロセスごと落ちた。

機能の追加分と、その途中で何が壊れて何を直したのかをまとめておく。対象は Pointer LockのPR #770 ElementInternalsのPR #775 を取り込んだ、9月20日の19ded7d0

Pointer Lockはカーソルを隠すだけではない

Pointer Lock は、マウスの移動を特定の要素へ送り続けるためのAPI。例えば3Dの視点操作では、画面上のカーソル位置よりも、前の入力からどれだけ動いたかが欲しい。

通常のカーソルは画面の端まで行くとそれ以上動かせない。Pointer Lockではカーソルを隠し、ページから見える位置を固定したまま、movementXmovementYで相対移動を受け取る。

今回追加したのはrequestPointerLock()exitPointerLock()pointerLockElementと、状態変更・エラーのイベント。それをGUIのカーソル制御と、ホストから届く相対入力へつないだ。

ここで、JavaScript側のフラグだけ先にロック済みにしてしまうと問題になる。OS側でカーソルの取得を拒否されても、ページには成功したように見えてしまう。

そこでネイティブGUIの経路では、ホストからの応答を待つようにした。

ページがrequestPointerLock()を呼ぶ
    -> 要求IDを付けて取得要求を保存
    -> GUIがOSのカーソル取得を試す
    -> 成否を要求IDと一緒に返す
    -> 対象Documentと要素がまだ有効か再確認
    -> DOMの状態、イベント、Promiseへ反映

Rust側でGUIへ渡す値は、次の形になっている。コメントを省いた実装の抜粋。

pub enum PointerLockTransition {
    Acquire {
        request_id: u64,
        unadjusted_movement: bool,
    },
    Release,
}

要求IDはページ遷移をまたいでも使い回さない。待っている間にフォーカスを失ったり、ページが切り替わったりした場合、古い成功応答で新しいページをロックしないため。

Pointer Lockの状態管理 では、要求キュー、現在の所有者、ホストへ送信済みかどうかを分けて持っている。取得が成功して返ってきても、その間に対象が無効になっていれば解除へ回す。

Esc、フォーカス喪失、対象要素の削除、navigationからも解除できるようにした。テストには、遅れて返ってきた応答を無視するケースも入れている。

なお、ヘッドレスでは仮想ホストが通常の取得を受理する。ネイティブGUIの実際の取得確認とは別の経路。また、OSの加速処理を除いた入力を要求するunadjustedMovement: trueは、現状の組み込みホストではNotSupportedErrorにしている。相対移動を扱えることと、このオプションを保証できることは分けた。

X11ではマウス入力が二重に届いた

APIとホストをつないだあと、LinuxのX11で相対移動とホイール入力が重複した。

Winitの通常のX11 confinement経路を使ったところ、raw motionが二重になる組み合わせがあった。ページ側で受け取った値を足すと、実際に与えた移動より大きくなる。

同じ移動量が続いたら片方を捨てればよさそうにも見えるが、それでは本当に同じ量だけ連続して動かした入力まで消える。値や時刻を見た後付けの重複排除にはしなかった。

X11では専用経路を作り、XGrabPointerowner_eventsをfalse、core mouse eventのmaskを0にして、WinitのXI2 raw inputを使うようにした。相対移動だけでなくボタンとホイールもその経路へ揃えている。

X11の取得処理 は、既存のWinitウィンドウと接続を使い、イベントループのスレッド上で実行する。X11以外はWinitの取得APIへ回す。

この問題はDOMだけのテストでは見えないので、Xvfb上でGUIを起動し、XTestで入力するテストも追加した。

与えた入力ページ側で確認する結果
(5000, -4000)の相対移動を1回イベント1回、移動量もそのまま
(12, -9)を続けて2回合計3回、累積(5024, -4018)
ロック中のクリックとホイールそれぞれ1回
Escで解除OSの取得解除、カーソル再表示、取得前の位置へ復帰

大きな移動でもページ座標は固定され、同じ値の連続入力も落とさないことを確認する。カーソルの非表示だけでなく、別のX11接続から取得を試し、OS側で本当に取得中かも調べている。

この GUIテスト はLinux x86_64とARM64のCIで実行している。ただし、Xvfbへの合成入力であって、物理マウスやWayland、macOSのデスクトップ操作まで検証した結果ではない。

iframeへ入力を送ると、実行するRealmも問題になる

次に、iframe内で登録されたイベントリスナーへホスト入力を届ける処理を直した。

Realmは、JavaScriptのグローバル環境や組み込みオブジェクトをまとめた実行環境。同一オリジンで相互にアクセスできるiframeでも、親と子が一つのRealmになるわけではない。

入力先のDOMノードが子Documentにあるなら、子側の入力配送処理へつなぐ必要がある。一方、フォーカスやリスナーの順序、キャンセル状態などは、親子で食い違ってはいけない。

今回のinput bridgeでは、Documentごとに配送用コールバックを登録し、共有が必要な入力状態は別に持つ。Documentが破棄されたら、配送先の登録も消す。

ところが、この経路を動かすとBoaのnative continuationにも不具合があった。

native continuationは、Rust側からJavaScriptのコールバックを呼び、その続きへ戻るための仕組み。入力リスナーがホスト呼び出しで一時停止する場合もあるため、単純な同期呼び出しの入れ子にはしていない。

問題は、ネイティブ関数がコールバック用のフレームを積んだあと、復帰処理が無条件に呼び出し元のRealmへ戻していたこと。これから動くコールバックの実行環境まで上書きしていた。

親側のネイティブ呼び出し
    -> 子Realmのコールバックフレームを準備
    -> ネイティブ呼び出しの後始末
    -> 親Realmへ戻す
    -> 子のコールバックを実行するはずなのに環境が違う

修正では、コールバックフレームが追加されたかをフレーム深さで判定する。追加されていればそのRealmを維持し、コールバックから戻るときに呼び出し元を復元する。

合わせてshadow stackの後始末も直した。単純に末尾をpop()すると、ネイティブ呼び出しのあとに積まれたコールバックのエントリを取り除いてしまう。追加時の位置を保持し、終了したネイティブ呼び出しのエントリだけを削除するようにした。

RealmとcontinuationのIssue #768 では、通常の呼び出しだけでなく、入れ子、例外、一時停止、GCを挟んだ再開を回帰テストへ入れている。

自作の入力部品をフォームへ参加させる

もう一つの追加が フォーム関連カスタム要素

カスタム要素で入力UIを作っても、見た目が入力部品になっただけではフォーム送信や検証には参加できない。今回追加したElementInternalsは、その接続に使う。

入口だけを示すと、次のようになる。これは値の登録を示す例で、入力UIそのものは省いている。

<form id="settings">
  <x-value name="answer"></x-value>
</form>

<script>
  customElements.define("x-value", class extends HTMLElement {
    static formAssociated = true;

    constructor() {
      super();
      this.internals = this.attachInternals();
      this.internals.setFormValue("42");
    }
  });

  const form = document.getElementById("settings");
  console.log(new FormData(form).get("answer")); // "42"
</script>

Omoikaneでは送信値として文字列、FileFormDatanullを扱う。FormDataなら一つの部品から複数のentryを登録でき、nullなら送信値を出さない。

設定したFormDataのentryはコピーする。呼び出し元が後から元のFormDataを書き換えたことで、登録済みの値まで変わらないようにしている。

値だけでなく、setValidity()による検証状態、フォームの送信可否、:valid:invalidの描画も接続した。関連するformやlabelも固定した値ではなく、DOMの変更に追従させる。

難しいのは、最初にフォームへ入れたときより、そのあと。別のフォームへの移動、form属性で指定したIDの変更、disabledなfieldsetへの出入り、resetなどで状態が変わる。

formAssociatedCallbackformDisabledCallbackformResetCallbackを用意し、DOM変更が完了してから通知する。通知の中でまたDOMが変更されても、途中までしか移動していない木を見せないため、reactionをキューへ積んでから処理している。

送信する値と、復元する状態は別にする

setFormValue()は二つ目の引数で、復元用の状態を設定できる。

// internalsはattachInternals()で取得したもの
internals.setFormValue("2026-09-20", "2026年9月20日");

この例なら、送信したいのは機械処理用の日付で、UIを復元するときに必要なのは別の表現、という使い分けになる。第2引数を省けば、送信値を復元用にも使う。

今回の実装でもsubmissionrestorationを別々に保持し、保存した状態をformStateRestoreCallbackへ渡す。

ここで古い要素やJavaScriptオブジェクトをそのまま保存すると、前のDocumentやruntimeまで生かし続けることになる。戻る操作で必要なのは値であって、前の実行環境そのものではない。

そこでRust側のFormStateSnapshotは、文字列化したデータを持つ構成にした。文字列、ファイルのバイト列と属性、FormDataのentryをシリアライズし、元のDOMやJSオブジェクトへの参照を持ち越さない。

復元先はDocument内の経路と要素名、nameidを照合する。カスタム要素の定義がまだ読み込まれていなければ保留し、後からupgradeされたときに復元処理へ進める。これは現実装の照合方法で、任意に構造が変わったページでも同じ入力欄を見つけられるというものではない。

復元の回帰テスト では、次の順番を実際に通している。

元runtimeで値を設定
    -> snapshotを取得
    -> 元runtimeをdrop
    -> 強制GC
    -> 新しいruntimeへsnapshotを渡す
    -> 後からcustomElements.define()
    -> イベントループを進める
    -> 復元callbackが一度だけ呼ばれることを確認

元のオブジェクトが偶然残っていて動いた、という確認にはしない。ファイルやFormData、iframeの再読込と履歴移動も別途テストしている。

フォームを実際に送るとiframe側も直す必要があった

フォームのWPTを動かすには、FormDataを作れるだけでは足りなかった。名前付きiframeへGETやPOSTで送信し、その中で結果を確認する経路が必要になる。

そこで既存iframeを送信先として解決し、そのDocumentだけを置き換える処理を追加した。テストではローカルHTTPサーバーでmethodやbodyを受け取り、親ページが置き換わっていないことも確認している。

その過程で、HTMLパーサーが持つform ownerの扱いも修正した。特にtableの構築では、完成後の親子関係だけからフォームとの関連付けを決めると、パーサーが設定した関連付けを落としてしまう。逆にDOM操作で移動したあとまで、古いパーサー由来の関連付けを残してもいけない。

iframe初期文書のinline classic scriptが実行されない問題も直した。さらに子のlocationhistoryを、親ではなく子自身の履歴へ接続した。

注意したのは、iframeの要素が同じでも、その中のDocumentは入れ替わること。

古い子Documentが持っていたhistory操作を、今のiframeへそのまま転送すると、新しい文書の履歴を変更できてしまう。 子文書のnavigation経路 ではDocumentの識別子を照合し、遅延していた読み込みが確定したあとにも、操作元がまだ有効か再確認する。

古いcallbackや復元処理を新しい文書へ適用しないことも、回帰テストへ入れた。

なお、新規ウィンドウを作るホストAPIはまだない。_blankや存在しない名前への送信は、勝手に現在のページを置き換えず、blocked popupとして扱う。初期scriptの今回の追加範囲もinline classic scriptで、iframe関連のすべてが完成したわけではない。

フォームを使っていないページまで遅くなった

ここまで追加したところ、Acid3のtest26が時間制限に達するようになった。

このテストはDOMノードの生成・挿入・削除を繰り返す。停止時のスタックには、今回追加したcustomFormSubtreeRemovingからcustomElementTreeWalkへ進む経路が含まれていた。

フォーム関連callbackを正しく呼ぶための走査が、フォーム関連カスタム要素を一つも使っていないページでも走っていた。通常のDOM変更へ追加した処理なので、機能を使わないページにもコストが乗っていた。

修正は、最初のフォーム関連カスタム要素が生成されるまで、専用の走査を省くこと。

ただし有効化フラグをRealmごとに独立させると、子で作った要素を親へ移したとき、親側が「使われていない」と判断して通知を省いてしまう。そのため、この状態はbrowsing context内のRealm間で共有する。

移動のテストを追加すると、さらに通知欠落とownerDocumentの不一致が見つかった。子で生成した要素を親へ移し、そこから切り離すと、所有文書が元の子へ戻る問題。

内部状態を弱参照でRealm間共有し、切り離したノードも最新の所有文書を見るように直した。要素単体の移動だけでなく、親要素ごと移した場合も確認している。

性能低下を追ったIssue #776 にある同一ローカルARM64環境でのtest26の比較は、次のとおり。

状態FaithfulDirectDrive
フォーム対応前2,331 ms2,546 ms
フォーム対応追加後3,021 ms3,339 ms
不要な走査を省いた段階2,469 ms2,632 ms

それぞれ1回の測定なので、一般的な高速化率としては扱わない。ここで見たいのは、新機能を使っていないページへの余計な処理が負荷増の一因になっていたこと。

Faithfulはページ本来のloadイベントとtimer連鎖でAcid3を進める経路、DirectDriveはrunner側から更新処理を直接駆動する経路。同じテストでも実行の進め方が違う。以前のGC調査の記事にもこの違いを書いた

ローカルの短縮だけでは完了にせず、最終コミットのCIも確認した。途中ではmacOSの別構成で時間切れが残っていたが、最終版では必要なCIが通っている。fixtureのループ数や時間制限を緩めたわけではない。一方、制限の5秒に近い結果もあり、性能に十分な余裕ができたとまでは言わない。

macOSのSIGSEGVは、時間切れとは別の問題だった

性能修正の検証中、macOS ARM64のbaseline JIT構成でAcid3のDirectDriveがSIGSEGVになった。

同じAcid3で失敗していても、時間制限に達したのと、プロセスがメモリ異常で落ちたのは別の現象。 Issue #777 へ分けて調べた。

変更前後のrevisionを固定し、通常実行とLLDB付き実行で比較した。停止経路は次のようになっていた。

Array::from
    -> get_iterator_from_method
    -> JsObject::call
    -> __call__

iterator用の関数を呼ぶとき、そのvtableを参照するところで落ちていた。

Array.from()は、入力からSymbol.iteratorのメソッドを取得したあと、出力用オブジェクトを作り、そのメソッドを使ってiteratorを取得する。 仕様上もこの順序になっている

GetMethod(items, Symbol.iterator)
    -> 出力オブジェクトのconstructorを実行
    -> 取得済みのメソッドからiteratorを取得

この間にGCが動く可能性がある。

Symbol.iteratorが普通のプロパティなら、入力オブジェクトが関数を持っている場合が多い。しかしgetterがその場で新しい関数を作って返す場合、入力側にその関数が保存されているとは限らない。

Boa側は取得した関数をRustのローカル変数へ置いていたが、出力constructorの実行中もGCから到達可能な参照として保持する処理が抜けていた。

Rustの変数がスコープ内にあることと、JavaScriptエンジンのGCがその値を生存中と認識することは別。必要なのは、この呼び出しの間は回収してはいけないというrootを登録することになる。

修正の本体は、constructorへ進む前のこの1行。

let using_iterator = using_iterator.root();

参照保持の修正コミット では、コメントを含めて3行を追加している。呼び出し順序を変えるのではなく、既存の順序のまま一時的な関数の寿命を保証する修正。

macOSのJIT構成で見つかったが、原因をmacOSやJIT固有とはしなかった。切り出した回帰テストは修正前のLinux ARM64でも失敗した。

強制GCするだけでは再現しなかった

回帰テストでは、getterが毎回新しいiterator関数を返し、出力constructorの中で強制GCするようにした。

ただ、最初にJavaScriptのgetterで書いたテストでは再現しなかった。

そのgetterの実行フレームに戻り値が残ると、本来調べたい参照保持漏れがあっても、別の参照に助けられて関数が生き残ることがある。

そこでテスト用のgetterをRustのnative getterにして、退役したJavaScriptフレームのレジスタに戻り値が残る経路を避けた。

native getterが新しい関数を生成
    -> Array.fromが関数を受け取る
    -> 出力constructorがforce_collect()
    -> 取得した関数を呼ぶ
    -> 値・長さ・出力の型・呼び出し回数を検査

これで修正前に失敗し、rootを追加したあとに成功する回帰テストになった。

修正後はmacOSの診断でも通常4回、LLDB付き4回がすべて成功した。その後、最終コミットのPR CIでも3環境のinterpreter・baseline JITを確認している。

たまたまCIを再実行したら通った、ではなく、失敗する条件を小さくしてから直せたのはよかった。以前修正したshared-shape cacheの寿命問題とも別の箇所だった。

今回の検証範囲と残っているもの

PR #775の最終head f9a23fbdについて、記録した結果は次のとおり。CIは当時のmainとのmerge revisionも使って検証している。

検証結果
ElementInternalsの回帰23件成功
実HTTP送信・iframe履歴・文書境界20件成功
validityの描画8件成功
ローカルの全体テスト2,694件成功
固定revisionのWPT smoke147件中146成功、既知失敗1、回帰0
ブラウザCI3環境×interpreter/baseline JITの6構成で、Acid3の両経路100/100
Test262比較各環境50,595ケース、参照版からの退行・panicなし
PRのチェック43成功、対象外2件skip

WPTは固定したsmoke対象の集計で、全WPTに準拠したという意味ではない。残った1件は前回からの ArrayBuffer転送とdetachmentの問題

Test262も全件成功ではなく、各環境47,616 PASS、既知923 FAIL、2,056 ignored。比較で退行がないことと、ECMAScript全体への準拠は区別する。

結果は 通常CI ブラウザ6構成 Test262比較 に残している。

ElementInternalsのARIA reflectionとCustomStateSetは今回の対象外。履歴entryごとのフォーム状態復元も、Omoikaneの現実装であり、同一文書履歴についてFirefoxと完全に同じ動作を確認したものではない。

目視中に見つかった 入れ子のfixed要素がずれる問題 も別件として残している。今回のvalidity描画テストは通常フローで状態変化を確認しており、この配置問題まで解決したことにはしていない。

入力とフォームを足した結果

今回ページから使えるようになったのは、相対マウス入力と、送信・検証・復元へ参加するカスタム要素。

その裏で、OSの取得完了を待つ処理、子Realmへの入力配送、古いDocumentを残さず値だけ復元する処理、GCをまたぐ一時参照の保持まで直すことになった。

フォームを使っていないページが遅くなったのも、機能追加時に見ておきたいところだった。新しいAPIの正常系だけ通していたら、どちらも気づかず残っていたと思う。

ひとまず今回の二つはmainへ入った。Pointer Lockの実機確認や、フォーム周辺の未対応部分はまだあるが、追加した処理と、そこで見つかった不具合は回帰テストと一緒に残した。