株式会社クリアコード > ククログ

ククログ


Tokyo WebExtensions Meetup #3

クリアコードでFirefoxやThunderbirdの法人サポートに従事している結城です。

去る10月30日、御茶ノ水・デジタルハリウッド大学院の1教室をお借りして、Firefox(やその他様々なブラウザ)の拡張機能に関心を持つ人達の交流イベントであるTokyo WebExtensions Meetup #3が開かれました。日にちが日にちだったため、会場にはハロウィン的な仮装のための小道具が用意されていたり、Mozillaのスポンサードにより提供されたピザがハロウィン仕様(黒い生地にオレンジのチーズ)だったりと、ささやかながらお祭り気分を味わいつつの進行でした。

筆者も発表者の一人として参加しましたので、このエントリでは発表の内容の概要をお伝えします。

発表1:タブの複数選択APIのつかいかた

最初は筆者による、Firefox 63以降で使える「タブの複数選択機能」に関する発表です。発表資料のスライドはQiitaにて公開中です。また、公開に際しては口頭で発表した詳細な説明も書き加えてあります。

タブの複数選択機能はFirefox 64からの新機能(予定)ですが、基本的な機能はFirefox 63の時点でも既に使える状態になっています。about:configを開いてbrowser.tabs.multiselectを検索し、値をtrueに設定すれば、機能が使えるようになります*1

筆者は予てよりマルチプルタブハンドラというアドオンを個人的に開発しています。これはFirefox 56以前においては、Firefoxのタブにまさにそのような性質を加える物でした。しかしWebExtensionsではFirefox自体のタブの振る舞いを変えることはAPIの制約上できません。そのため、WebExtensions移行に際しては、このアドオンの内部でタブの選択状態を管理し、browser.runtime.sendMessage()による明示的なAPI呼び出しに対してその情報を返すという形で、他のアドオンに対して「タブの複数選択」相当の事ができるようにするAPIプロバイダのように振る舞う*2アドオンとして再出発したという経緯があります。

今回、Firefox本体にタブの複数選択機能が入り、同時にその機能を呼び出すためのWebExtensions APIが追加された事によって、アドオン同士がお互いの存在を知らないまま暗黙的に連携しあえる場面がまた一つ増えたと言う事ができます。また、次のリリースとなるFirefox 64ではタブやブックマークのコンテキストメニューに機能を追加するアドオン同士の暗黙的な連携も可能になる予定です。このように暗黙的な連携が図られる場面が増えることで、各アドオンが単独で動作するだけの状態に比べて、機能の有用さは何倍にも高まります。

XUL時代のアドオンの有用性を支えていた要素の一つであったその性質が、WebExtensionsへの移行で大きく失われてしまった、という体験をした身としては、今後もこういった形でアドオン同士の暗黙的な連携が促進されるような改良が続く事を切に期待するばかりです。

発表2:Webページ中のテキストの動的な置換

2番目の発表は、あきみね氏による「Webページ中に現れる特定の文字列を、任意の文字列に強制的に置き換える」アドオンの紹介でした。

このアドオンでは、コンテントスクリプトでDOMツリーを走査して個々のテキストノードを編集するという基本の処理に加えて、MutationObserverを使って動的に挿入されるテキストも編集するという工夫により、Facebookなどのような「スクロールに応じてページの内容が次々と継ぎ足されていく」形式のWebサービスにも対応できるようにした事が特長であるという紹介がありました。

ただ、Webページ中の全テキストノードを走査するという事は、それだけ処理に時間がかかるという事でもあります。そのため、処理に時間がかかるようなページのための対策には悩まれているそうです。

これについて、browser.findのAPIを使用すればより効率よく指定の文字列の置き換えができるのではないかという情報提供を筆者から行いましたが、このAPIは今のところFirefoxが対応しているのみのため、Chromeの使用を考えると残念ながら使う事はできないとの事でした*3

ブラウザを問わずに行えそうな別方向での最適化としては、worker-domを活用するという方法も考えられます。これはDOMツリーを丸ごとWorker上に再現して、ボトルネックになりがちなDOMの操作を別スレッドに逃がしてしまうという試みです。一般的なWebページの処理にどこまで使えるかというのは未知数ですが、もし使えるのであれば、Webページの内容と密接に関わる種類のアドオンの大幅な高速化が可能になるかも知れず、興味深い技術です。

発表3:WebExtensionsとテスト

3番目の発表は、Firefox上でvim風の操作体系を実現するアドオンの一つであるVim Vixenの作者のUeoka氏による、アドオンの自動テストの実現方法についてのお話でした。
こちらは発表資料がSpeakerDeckにて公開されています

Vim Vixenの開発にあたっては一般的なJavaScriptでのアプリケーション開発のノウハウが活かされていて、自動テストもKarmaMochaWebExtensionsの各種APIをモックするライブラリを組み合わせて大量のユニットテストを行っているそうです。また、自動テストやESLintによる検証をCircleCIで継続的に実行されているとの事でした。

プロダクト自体を自動テストが容易になるように設計するという事は、純粋なロジック部分と外部要因になる部分とをなるべく切り離すという事です。例えばタブを指定の条件でフィルタリングしてリストアップするためのフィルター処理自体browser.tabsに一切触れておらず、その自動テストも純粋なロジックのみに特化して検証できています。Vim Vixenでは全体をこのように設計し、テストをなるべくしやすいようにされているとのことでした。

発表の後半はE2Eテスト(ユーザーの実操作に近いテスト)にフォーカスした話でした。Vim Vixenでは163あるE2Eテスト項目のうち現時点で45件を自動化できているとのことですが、その実現にあたっては、ambassadorと名付けられたテスト実行専用のアドオンを使われているそうです。WebExtensionsベースのアドオンの開発・テスト支援ツールであるweb-extの改造版を用いてVim Vixenとambassadorを同時に読み込ませ、テストケース内で指示された操作をambassadorで行ってVim Vixenの動作をテストしているとの事でした。

ユーザーの入力を再現するような箇所は非同期処理が多くなりがちですが、スクロール操作のE2Eテストなどを見ると分かるように、非同期処理が絡む自動テストもawaitを使うとシーケンシャルに記述できます。async/awaitはテストの実装にも有用という事を示す一例と言えるでしょう。

Vim Vixenはコンテンツ領域内にUIを挿入する設計のためこのようなテスト手法が可能となっているそうですが、言い換えると、サイドバーやツールバー上のポップアップ内でUIを提供するアドオンについては、このアプローチでのテストは行えないという事でもあります。Ueoka氏自身も、より広い範囲のE2Eテストを自動化する方法について試行錯誤を続けられているそうです。

アドオンの自動テストについては、Firefox本体におけるWebExtensions APIそのものの自動テストの実装方法が参考になりそうな所ではあるのですが、Firefox内のWebExtensions APIのテスト群は専用のテストランナー上で専用のユーティリティを使って実行する形を取っており、しかもその内容はFirefoxの名前空間上で動作するという、現状のFirefoxの実装に強く依存した形式となっています。web-extやその後継ツールには、Firefox本体の内部事情に詳しくなくても同等の事ができるような自動テスト支援の仕組みを提供してくれる事が期待されます。

ambassadorや実際の自動テストも含めて、この発表の中で紹介された要素はすべてVim Vixenのリポジトリで公開されています。拡張機能の自動テストに関心がある方は必見ですね。

11月8日追記:発表をされたUeokaさんご自身による発表内容の解説記事も併せてご覧下さい。

発表4:開発ツールを拡張しよう!

最後の発表は、Mozillaで主にJavaScriptエンジンの開発を担当されているarai氏による、Webページのソースを任意の内容で置き換えるDITMというアドオンの紹介でした。

DITMとは「Developer In The Middle」の略ですが、これは「Man In The Middle Attack(中間者攻撃)」という攻撃手法をもじった物です。

MITM攻撃では、ユーザーが操作するクライアントとWebサーバの間に何らかの方法で中継者が入り込み、ユーザーが送るリクエストの内容を書き換えてWebサーバーに送ったり、Webサーバーから返された内容を書き換えてクライアントに送ったりすることで、ユーザーが入力したパスワードやクレジットカード番号などの機密情報を盗み出したり、ユーザーを望ましくない行動に誘導したりといった事が行われます。昨今のいわゆる「常時SSL化」は、このような攻撃を防ぐ事を目的の1つとしています。

このようにMITM攻撃を防ぐ事のできるSSL(TLS)ですが、アドオンの権限ではTLSで通信して取得した後の内容を任意の物に置き換える事ができます*4。これを応用して、Webサーバー上のファイルを実際に編集せず手元で書き換えてデバッグするという事を可能にするのが、DITMです。

発表では、「Minifyされたライブラリのソースを一旦スクラッチパッドに貼り付けてpretty printし、それを元のファイルの代わりに使うようにDITMで設定した上で、任意のコードをそこに追加して実行してみる」というデモが行われていました*5。「Firefoxでだけ動かないページ」に遭遇した時の原因調査などに活用できるかもしれません。

DITMの主要な機能はbrowser.webRequest.filterResponseData()によって実現されているため、Web上のコンテンツをクライアント側で置き換える*6実装の例と言う事ができます。また、開発ツールに独自のタブを追加する例としても参考になるでしょう。

次回以降のイベントについて

メインの発表が終わった後は、このイベント自体の今後の方向性についてヒアリングも兼ねたディスカッションが行われました。以下はその中で聞かれた意見です。

  • 一般的なセミナー形式ではなくワークショップをやりたい。
    • アドオンを作ってみたい、という初心者向けの内容はどうか。
    • 既存のアドオンの不具合の修正や機能追加のプルリクエスト、WebExtensionsに移行できていないアドオンの移行をやってみるのはどうか。
    • 日本語ロケールが無いアドオンを翻訳してプルリクエストするのはどうか。
    • Firefox本体に対するWebExtensionsの新しいAPIの提案や、実装が行われないまま停止しているAPI案へのパッチの提供など、やや高度な事に挑戦してみるのはどうか。
    • モブプログラミング形式(手を動かす人一人に対し周囲に複数人が集まって意見を出し合う形式)にしてはどうか。
  • 温泉旅館などでハッカソンのように泊まりがけてディープな事をやってみたい。
  • セミナー形式のイベントにニーズが無いわけではない。今回の発表も刺激的な内容だった。

ひとまず、次回開催は1月下旬から2月上旬頃を目標として、どのようなイベントが望まれているかのアンケートなどを近日中にMozilla Japan コミュニティのSlackで行う事を予定しています。次回の参加を検討されている方は、自分が参加したいと思えるイベントにしていく機会として、是非Slack上で意見を表明してみて下さい。

また、「OSSであるFirefoxやアドオンにフィードバックしてみたいが、やり方が分からなかったり、恐怖感があったりして、一人でやるのは尻込みしてしまう」という人向けに、Firefox関係に限らず広くOSS一般を対象にしたOSS Gateという取り組みも行っています。こちらはワークショップが定期的に開催されていますので、開催予定のイベント一覧から、参加しやすいエリア・日次のワークショップを探してビギナーとして参加してみてはいかがでしょうか。

まとめ

以上、Tokyo WebExtensions Meetup #3で行われた発表やディスカッションの内容をご紹介しました。

*1 なお、タブの複数選択機能はChromeには遅くとも2013年頃の時点で既に実装されていた模様です。

*2 とはいえ実際には、大多数の他のアドオンはこのアドオンの存在自体を知らないために、明示的な連携が図られる事はほぼ無く、「アドオン同士の連携」といってもほとんど絵に描いた餅に過ぎないという状態が長く続いていました。

*3 同様の事をクロスブラウザで行う物ための技術としては、かつてW3Cで提案されていたRangeFinder APIという仕様もありますが、こちらは現在の所実装しているブラウザは存在しません。

*4 そのため、怪しいアドオンを不用意にインストールするのは非常に危険な事と言えます。

*5 Minifyやコンパイルされたコードに対するデバッグ方法としては、変換前のソースとの対応表を提供するSource Mapという技術もあります。Source Mapが提供されていない場合でもそれと同様の事を行えるというのが、DITMの利点の一つという事になります。

*6 あきみね氏の発表はDOMツリーが構築された後の置き換えなのに対し、arai氏のDITMはDOMとして解釈されるより前の時点での置き換えという違いがあります。

タグ: Mozilla
2018-11-02

Firefoxのメモリ消費量が右肩上がりで増加する場合の対策

FirefoxをWindows Serverベースのシンクライアント環境で使っている組織において、Firefoxを更新したりWindows Serverをリプレースしたりといったタイミング以降で、急にパフォーマンスが低下しだしたというお問い合わせを複数頂きました。調査の結果、それらの現象は同一の原因である可能性が高い事、同様の対策が有効に作用する事が分かりましたので、その際に得られた調査結果を知見としてご紹介します。

なお、本エントリで紹介する設定はすべてFirefox ESR52とFirefox ESR60向けの物です。 将来のバージョンのFirefoxでは有効でない可能性がありますので、ご注意下さい。

パフォーマンス低下の原因

お問い合わせを頂いた複数の事例において、サーバー全体のリソース状況を監視して頂いた所、業務の開始と共にメモリの消費量が右肩上がりに増大しており、退勤時刻になってユーザーがログオフし始めるまでメモリ不足の状況が続くという状況である事が分かりました。また、この時メモリを最も消費しているのはFirefoxのプロセスであるという事も判明しました。

(メモリ消費量の傾向のイメージ)

いくつかの事例では、FirefoxやWindows Serverのリプレース以前にはこのような現象は発生していなかったという事が分かっていました。そのため、これは単純にメモリ消費量が増大したというよりも、 使い終わったメモリ領域がいつまでも開放されないという問題(いわゆるメモリリーク) であると考えられました。

一般に、プログラムの動作速度と消費リソースの量は比例します。現在のFirefoxは体感速度の向上に努めているため、キャッシュを多めに保持したり、複数のプロセスを並行動作させたりといった形でリソースの消費が多くなりがちです。その過程で必ずしも必要でないような大量のメモリ領域を確保する事があり、それが何らかの理由で解放されないままとなってしまうと、物理メモリの枯渇に繋がり、ディスクドライブ上の仮想メモリとの間でスワップが頻発して却ってパフォーマンスが悪化してしまう事になります。

よって、この問題を解消するためには何らかの方法でメモリ消費量を削減する必要があります。

端的にメモリ消費量を抑える

Firefoxが消費するメモリの上限を強制的に設定する事はできないのでしょうか? 実は、できます。 Firefox全体での消費メモリ量の上限は設定できませんが、FirefoxのJavaScript実行エンジンが使用できるメモリの量は上限を設定できるようになっており、例えば以下のようにするとJavaScript実行エンジンの使用可能なメモリの上限が1GBに設定されます。

// 単位はbytes。
// 1GB=1024MB、1MB=1024KB、1KB=1024bytesのため、
// 1024 * 1024 * 1024 で1GBを表す。
lockPref("javascript.options.mem.max", 1024 * 1024 * 1024);

このlockPref()という書き方は、MCDと呼ばれる設定方法に特有の物です。Windows Serverであれば、そのマシン上のFirefoxのインストール先に上記の内容を記載したautoconfig.cfgを設置することで、そのWindows Severにログオンするすべてのユーザーに設定が反映されます。

ただし、この方法で上限を設定した場合に上限を超える量のメモリが必要な状況が発生すると、Firefoxがクラッシュしたり、フリーズしたり、Webページを表示できなくなったりといったトラブルが発生する可能性があります。 現代のWebページは1ページあたり数百MBのメモリを消費する事も多く、安心して動作できるだけの数値を設定すると、多人数でリソースを共有するWindows Serverにおいては結局は各ユーザーで物理メモリを容易に食い潰してしまうという結果になるでしょう。 ですので、この設定は今回のような事例では実際には有用ではありません。

Firefoxのプロセス数を減らす

安全に行える範囲の設定では、マルチプロセス機能の無効化がメモリ消費量の削減にある程度有効です。

現在のFirefoxは、体感速度や安定性、セキュリティを向上する事を目的に、処理を複数のプロセスで並行して行う設計となっています。 しかし基本的にFirefoxはそれなりの量のメモリを消費する設計のため、プロセス数が増えれば増えるほどその分余計なメモリを消費する事になります。 マルチプロセス機能を無効化すると、前述の利点を失う事と引き替えにメモリ消費量を削減する事ができます。

マルチプロセス機能についてはFirefoxの設定画面からもある程度の設定は変更できますが、抜本的な無効化には隠し設定の変更が必要です。 前述のMCDで行う場合は以下のようになります。

// マルチプロセス機能の無効化
lockPref("browser.tabs.remote.autostart", false);
lockPref("browser.tabs.remote.desktopbehavior", false);
lockPref("dom.ipc.multiOptOut", 1);
lockPref("browser.tabs.remote.force-enable", false);

ただ、メモリ消費量の増大がメモリリークに起因している状況では、これも根本的な対策とはなりません。 プロセス数が少ない分、メモリ消費量の増大ペースは緩やかになる事が予想されますが、それでもいつかはメモリの枯渇が発生します。

開けるタブの最大数を制限する

プロセス数の削減と似た対策として、Firefox上で開けるタブの最大数を制限するという方法もあります。 これはFirefox本体の設定のみでは不可能で、Lean Tab Limiterというアドオンを導入する事で行えます。 全体向けに導入する場合、ポリシー設定でpolicies.Extensions.Installを設定するか、特定のフォルダにファイルを設置することによるサイドローディング若干情報が古い日本語での解説)でインストールする必要があります。

ただし、2018年10月24日現在公開されているLean Tab Limiter 0.1は管理者による設定の変更に対応していないため、タブの最大数の制限は各ユーザーが設定しなくてはなりません。 管理者側でタブの最大数を設定したい場合は、Storage Manifestを使った管理者による設定に対応した改造版であるLean Tab Limiter Advancedを使う必要があります。以下は、タブの最大数を5に設定しつつ、制限を超えた数のタブを開こうとした場合に通知メッセージを端的に表示する場合の設定例です。

{
  "name": "lean-tab-limiter-advanced@clear-code.com",
  "description": "Managed storage manifest for lean-tab-limiter-advanced",
  "type": "storage",
  "data": {
    "tab-limit": 5,
    "notify-blocked": true,
    "notify-blocked-title": "%s+1個以上のタブ及びウィンドウは開けません",
    "notify-blocked-message": " "
  }
}

原因を踏まえてメモリの過大な消費を抑制する

Firefoxにおいてメモリリークに類する現象が発生する場合、ガーベジコレクションが適切に実行されていない事が原因となっている場合があります。

ガーベジコレクションはJavaScriptなどの言語において不要になったメモリ領域の回収・解放を行う仕組みですが、ガーベジコレクションを行うためには基本的に他のすべてを停止する必要があるため、単純に頻繁に実行すると体感のパフォーマンスが低下します。 そのため初期状態では、一定時間以上操作が行われていなかったり、改修が可能そうなメモリの量がある程度以上の量溜まっていると検出されたり、といった条件が満たされた時に初めて実行されるという設定になっています。 よって、何らかの理由からガーベジコレクションの実行条件が満たされにくい状態にあると、未使用メモリが回収されないまま新たにメモリが確保されるという事が繰り返されるために、見た目のメモリ消費量が右肩上がりに増大してしまうという状況が発生します。

また、Firefoxの見た目のメモリ消費量が急上昇する背景には、Firefoxの「こまめにメモリ領域を確保するのではなく、ある程度のまとまった量のメモリ領域を先んじて確保しておき、必要に応じてFirefox内部でそれを小分けにして使う」という戦略があります。 これは、OSに対してのメモリ確保要求は一般的に時間がかかる事が多いため、このような戦略をとった方が体感速度の向上に繋がるという理由によるものですが、この事とガーベジコレクションが実行されないという現象が重なると、見た目のメモリ消費量が異常な速度で上昇していくという状況が発生します。

よって、以下のような設定を行えば、体感速度の低下と引き替えにメモリ消費量の異常な増大を防ぐ事ができると考えられます。

  • ガーベジコレクションの実行頻度を上げる。
  • 一度に確保するメモリ領域の大きさを小さくする。

実際に、デスクトップPCに比べて搭載メモリ量が小さいAndroid端末用の初期設定は、Firefox 52時点ではそのような趣旨に沿った内容になっています*1

以上の事を踏まえての、効果が大きく且つ体感速度の低下度合いが小さいと考えられる設定項目とその設定値の例は以下の通りです。

// GCを実行する基準にするメモリ消費量(単位:MB)。0〜1000の間で設定。
// 数値を小さくする事で、GCの実行頻度を上げ、
// パフォーマンス低下と引き替えにメモリ使用量を削減できる可能性がある。
// 初期値は30。
lockPref("javascript.options.mem.gc_allocation_threshold_mb", 3);

// 高頻度でGCとメモリ領域の確保が実行される場面での、最大の
// メモリ確保増加量(パーセンテージ)。
// 値を小さくする事で、パフォーマンス低下と引き替えにメモリ使用量を
// 削減できる可能性がある。
// 初期値は300。
lockPref("javascript.options.mem.gc_high_frequency_heap_growth_max", 150);

// 高頻度でGCとメモリ領域の確保が実行される場面での、最小の
// メモリ確保増加量(パーセンテージ)。
// 値を小さくする事で、パフォーマンス低下と引き替えにメモリ使用量を
// 削減できる可能性がある。
// 初期値は150。
lockPref("javascript.options.mem.gc_high_frequency_heap_growth_min", 150);

// 高頻度でGCとメモリ領域の確保が実行される場面での、最大の
// メモリ確保増加量(単位:MB)。
// 値を小さくする事で、パフォーマンス低下と引き替えにメモリ使用量を
// 削減できる可能性がある。
// 初期値は500。
lockPref("javascript.options.mem.gc_high_frequency_high_limit_mb", 100);

// 高頻度でGCとメモリ領域の確保が実行される場面での、最小の
// メモリ確保増加量(単位:MB)。
// 値を小さくする事で、パフォーマンス低下と引き替えにメモリ使用量を
// 削減できる可能性がある。
// 初期値は100。
lockPref("javascript.options.mem.gc_high_frequency_low_limit_mb", 100);

// GCを行うしきい値となるゾーンごとのGC対象のメモリ使用量(単位:MB)。
// 数値を小さくする事で、より頻繁にGCが実行されるようになり、
// パフォーマンス低下と引き替えにメモリ使用量を削減できると考えられる。
// 初期値は128。
lockPref("javascript.options.mem.high_water_mark", 16);

また、まだお客様環境への反映実績はないものの、Firefox ESR60の時点でデスクトップ版とAndroid版の間で初期設定が変更されている項目として以下の設定も有効である可能性があります。

// GC対象になりやすい短命なオブジェクトを保持するためのメモリ領域の最大サイズ(単位:KB)。
// 数値を小さくする事で、単純に必要なメモリ領域の量を削減できると考えられる。
// また、空きメモリ領域がこの値より小さい時は常にアイドル時にGCが実行される。
// デスクトップ版Firefoxでの初期値は16384、Android版の初期値は4096。
lockPref("javascript.options.mem.nursery.max_kb", 4096);

実際に、お問い合わせがあったお客様の環境ではこれらの設定によってメモリ消費量の右肩上がりの増大が収まり、旧環境とほぼ同水準のメモリ消費量に収まるようになったという結果が得られています。 しかしその一方で、消費リソースを削減するという事から事前に予想された通り、体感速度の低下も発生しており、ユーザー体験の悪化が許容範囲内となるバランスを模索する必要があったようです。 本エントリを参考に設定を反映する場合でも、上記の設定を叩き台として、ピーク時にもFirefox以外のアプリケーションを実行できる程度の空きメモリ領域を確保できる程度の範囲で、なるべく体感速度が損なわれないよう数値を調整する事を強くお勧めします。

その他、メモリ消費量と体感速度に関係している可能性がある設定

なお、Firefox ESR52を対象とした調査の過程で、メモリ消費量と体感速度に影響を与える可能性がある設定項目として、前項に挙げた項目の他にも以下の物がある事が分かりました。 それぞれどの程度の効果があるかは不明ですが、参考として記載しておきます。

  • javascript.options.asmjs(初期値:true):asm.jsのコンパイラを有効にする。無効化する事で、パフォーマンス低下と引き替えに若干メモリ消費が減る可能性がある。
  • javascript.options.baselinejit(初期値:true):Firefox上で実行されるJavaScriptの全てについて、実行時コンパイルを行い処理を高速化する。無効化する事で、パフォーマンス低下と引き替えに若干メモリ消費が減る可能性がある。
  • javascript.options.compact_on_user_inactive(初期値:true):ユーザーが離席している時にGCを行う。無効化するとGCの実行頻度が下がる。
  • javascript.options.compact_on_user_inactive_delay(初期値:300000):ユーザーが離席していると判断する時間(ミリ秒)。初期値は30秒だが、これを小さくするとより頻繁にGCが実行され、メモリ消費が減る可能性がある。
  • javascript.options.discardSystemSource(初期値:false):コンパイルした関数のソースをメモリ上から破棄するかどうか。有効化する事でメモリ消費が減る可能性があるが、Function.prototype.toSource()が機能しなくなるため、この機能を使用しているスクリプトを実行する場合は期待通りに動作しなくなる恐れがある。
  • javascript.options.gc_on_memory_pressure(初期値:true):空きメモリが逼迫して充分な使用メモリ領域を確保できなかった時にGCを実行するかどうか。無効化するとGCの実行頻度が下がる。
  • javascript.options.ion(初期値:true):Firefox上でのJavaScriptの実行時コンパイルについて、新型のエンジンであるIonMonkeyを使用する。無効化する事で、パフォーマンス低下と引き替えに若干メモリ消費が減る可能性がある。
  • javascript.options.ion.offthread_compilation(初期値:true):IonMonkeyにおいて別スレッドでコンパイルを実行する。無効化する事で、パフォーマンス低下と引き替えに若干メモリ消費が減る可能性がある。
  • javascript.options.mem.gc_compacting(初期値:true):GCの実行時に使用メモリの縮小も行うかどうか。インクリメンタルGCの場合には影響しない。無効化するとメモリ使用量が増大する可能性があり、変更しない事が望ましいと考えられる。
  • javascript.options.mem.gc_dynamic_heap_growth(初期値:true):使用メモリ領域を確保する時の一度に確保するメモリの量を動的に変えるかどうか。無効化すると「300%」固定になる。使用メモリを最小化する事を優先する場合、300%固定にされるよりは、動的な計算でgc_low_frequency_heap_growthgc_high_frequency_heap_growth_maxで上限を110%などに設定した方が有効と思われる。
  • javascript.options.mem.gc_dynamic_mark_slice(初期値:true):高頻度でメモリ領域の確保が行われている時に、インクリメンタルGCにおいて処理単位を大きくする。無効化するとインクリメンタルGCの効率が落ちメモリ使用量が減りにくくなると予想される。
  • javascript.options.mem.gc_high_frequency_time_limit_ms(初期値:1000):GCとメモリ領域の確保が頻繁に行われていると判断する基準の時間(ミリ秒)。値を大きくすることで、パフォーマンス低下と引き替えにメモリ使用量を削減できる可能性がある。
  • javascript.options.mem.gc_incremental(初期値:true):インクリメンタルGC(段階的なGC)を行うかどうか。無効化するとGCの実行頻度が下がりメモリ使用量の増大に繋がる可能性がある。
  • javascript.options.mem.gc_incremental_slice_ms(初期値:10):インクリメンタルGCを行う時間間隔(ミリ秒)。既に充分小さい間隔なので、間隔を小さくして頻度をこれ以上上げてもメモリ使用量の削減という効果はあまり変わらないと予想される。逆に、間隔を大きくして頻度を下げるとメモリ使用量が増大すると考えられる。以上の事から、変更しない事が望ましいと考えられる。
  • javascript.options.mem.gc_low_frequency_heap_growth(初期値:150):低頻度(1秒に1回レベル)でGCとメモリ領域の確保が実行される場面での、メモリ確保増加量(パーセンテージ)。値を小さくする事で、パフォーマンス低下と引き替えにメモリ使用量を削減できる可能性がある。
  • javascript.options.mem.gc_max_empty_chunk_count(初期値:30):GCに使用するメモリ領域について、キャッシュ的に確保しておく領域の最大数。小さい値を設定すると、パフォーマンス低下と引き替えにメモリ使用量を削減できる可能性がある。
  • javascript.options.mem.gc_min_empty_chunk_count(初期値:1):GCに使用するメモリ領域について、キャッシュ的に確保しておく領域の最小数。小さい値(0)を設定すると、パフォーマンス低下と引き替えにメモリ使用量を削減できる可能性がある。
  • javascript.options.mem.gc_per_zone(初期値:true):GCをゾーンごとに行うかどうか。インクリメンタルGCが有効な場合はこの設定に関わらずインクリメンタルGCが行われれるので、設定変更の必要はないと考えられる。
  • javascript.options.mem.gc_refresh_frame_slices_enabled(初期値:true):インクリメンタルGCの進行中に、メモリの回収を細かい単位で行うかどうか。無効化するとメモリ使用量が増大する可能性がある。
  • javascript.options.parallel_parsing(初期値:true):JavaScriptの解釈を別スレッドで行う。無効化すると、パフォーマンス低下と引き替えにメモリ使用量を削減できる可能性がある。
  • javascript.options.showInConsole(初期値:true):JavaScriptの内部エラーをコンソールに表示する。無効化するとコンソールのためのバッファの消費が減るため、デバッグ情報を収集しにくくなる事と引き替えにメモリ使用量を削減できると考えられる。
  • javascript.options.wasm_baselinejit(初期値:false):WebAssemblyの実行をさらに高速化するかどうか。有効化するとパフォーマンス改善に繋がるが、メモリ使用量は増大すると予想される。

まとめ

以上、Firefoxのメモリ使用量が右肩上がりに増大する状況でのメモリ使用量削減に効果があると思われる対策をご紹介しました。

当社のOSSサポートサービスでは、このエントリに記載したような、企業でFirefoxを運用中に発生したトラブルについての原因および解決策の有無の調査を有償にて承っております。 FirefoxやThunderbirdの運用でお困りの情報システム担当社の方は、お問い合わせフォームよりお問い合わせ下さい。

*1 一方、現在はこれらのAndroid版用の設定はなくなっていて、一部を除くとデスクトップ版と共通の設定値になっています

タグ: Mozilla
2018-10-24

macOS版FirefoxのIPCとドラッグ&ドロップ周りのバグを調査する

クリアコードの結城です。

先日、動画でバグ報告する方法の中で例として挙げていたmacOS版Firefoxのサイドバーの操作に関するバグですが、最終的には原因の特定とパッチの提出にまで至る事ができました。このエントリでは当該バグの調査の過程を辿りながら、macOS上のFirefoxにおいてマルチプロセスが絡む部分やドラッグ&ドロップに関わる部分のデバッグをどのように進めたかの一例をご紹介します。

問題の概要

当該バグはツリー型タブというアドオンに寄せられた報告に端を発しています。このアドオンはFirefoxのサイドバー内でタブバーの代替となるUIを提供するという物で、項目をクリックするとタブが切り替わり、ドラッグ&ドロップでタブを並べ替える事もできます。このサイドバーパネル上において「セッション復元後にまだ読み込まれていないタブをドラッグしようとするとドラッグできず、また、それ以後サイドバー内で一切のドラッグ操作が行えなくなる」という現象が発生する、というのが報告の要旨でした。

報告者の方と連絡を取りながら調査を進めた結果、この問題はmacOSでのみ発生するらしいという事と、アドオン側で可能と思われる対策を講じても現象を回避できない、Firefox自体のバグに起因する問題であるらしいという事が分かりました。当該アドオンの実装を参考に最小のテストケースを作成して検証した結果、確かに現象を再現できたため、Firefox自体のバグとしてBugzillaに報告しました。またその過程において、上記の再現手順の他に以下の事も事が分かりました。

  • サイドバーの内容(=アドオンが提供するコンテンツ)が別プロセスで動作している時だけ現象が再現する。
  • 既にセッションが完全に復元された状態のタブでは、現象は再現しない。
  • 現象発生時は、dragstartイベントは発生するが、その後に発生するはずのdragoverdragenterdragleavedragendの各イベントは発生しない。

調査前の時点での見立て

本格的に調査を始める前に、この時点で分かっていた情報から、Firefoxの内部で何が起こりどうしてこの問題が起こっているのかという事について、いくつかの仮説を立てました。

  • 仮説1:タブを切り替えてセッション復元処理が行われる時に、親プロセスと子プロセス群との間の「繋ぎ替え」が発生し、サイドバーのプロセスが「アクティブな子プロセス」でなくなるせいで、サイドバーのプロセスから親プロセスにドラッグ操作が伝わらなくなる。
  • 仮説2:タブを切り替えてセッション復元処理が行われる時に、ドラッグ&ドロップの処理に対して何らかの割り込みをかけるせいで、「何らかのフラグ」が内部で立ってしまい、以後ドラッグ&ドロップのイベントが発生しなくなる。

ただ、そもそも一連の処理に関わる実装がどのような設計になっているのか、「繋ぎ替え」や「フラグ」とここで呼んでいるような物が実際にあるのかどうかも、この時点では不明なままでした。これらはあくまで、過去の経験に基づく推測での見立てとなります。

また、筆者はmacOSのネイティブアプリ開発の知見を全く持っていないという問題もありました。筆者はWindows版のFirefoxの低レイヤ部分の調査は若干経験しているものの、macOSネイティブの開発経験は皆無です。今回のように特定プラットフォームでしか発生しない問題は、そのプラットフォーム向けのコード(WindowsではWin32 API、LinuxではGTK+、macOSではCocoa APIを使う部分)を調査する必要が出てきます。調査対象の事を全く知らない状態での手探りの調査は泥沼化しがちですので、これは大きなリスクです。

そのリスクを回避するために「macOSアプリでのドラッグ&ドロップはどのように実装するのか」という事を先に勉強してから調査に臨むというやり方もあります。しかしながら、今回の調査に必要な知識がその知識体系のどのあたりに存在しているかは全く予想が付きませんし、そこまでガッツリとmacOSでの開発を学びたいという訳でもありません*1。そもそもこれは「解決できれば儲け物だが、自身がmacOSユーザーではないので究極的には直らなくても困らない問題」という性質の物でしたので、失敗に終わったとしてもそれほどダメージは無いと言えます。ですので、知見の不足には一旦目を瞑って、Mozilla製品でのクロスプラットフォームな開発の知識だけを元に調査していく事にしました。

調査の出発点を探す

全く手がかりがない所から調査を行うのは、Firefoxほどの規模のソフトウェアではほぼ不可能です。そこで、ここまでに分かっていた事からヒントになりそうな要素はないかを検討してみました。

成功ケースと失敗ケースの両方の再現手順がはっきりしているという事は、その両者で起こっている事を子細に比較していけば、現象が起こる原因が分かる可能性は高いと考えられます。一連の操作の中で「共通してこのモジュールのこのメソッドが呼ばれている」という部分が特定できれば、そこを手がかりにして調査範囲を広げていけるはずです。

(ある一点を起点にして、成功ケースにまで辿り着くルート上のどこかで、失敗ケースへと分岐するポイントがあるはず)

ここで着目したのは、正常な場合でも異常な場合でもDOMのdragstartイベントは発行されているという点でした。Firefoxの内部ではこういったDOMイベントの名前は定数で定義されていますので、その定数を伴ってイベントを初期化したり発行したりしていそうな所を特定できれば、そこを起点に調査を進められます。

"dragstart"という文字列でざっとソースコード内を検索してみた所、dragstartを定数として定義しているらしい箇所がすぐに見つかりました。ここで、このイベント名は他の箇所からeDragStartという定数名で参照されているらしいという事が分かりました。

次に、eDragStartという定数名でソースコード内を検索してみた所、自動テスト用に擬似的にイベントを生成しているらしい箇所に混ざって、EventStateManagerというモジュールの中でイベントを生成しているらしい箇所が1箇所だけ見つかりました。この処理が含まれているのはEventStateManager::GenerateDragGesture()というメソッドでしたので、メソッド名から見ても、どうやらドラッグ&ドロップの操作が行われた時には必ずここを通過すると考えて良さそうです。

同様にeDragOverという定数名でソースコード内を検索した結果、成功ケースにおける「dragoverイベントが発行されるのはここ」という場所も特定できました*2

ところで、今回の問題は厳密に言うと、以下の2つの現象が起こっています。

  • セッション復元後にまだ読み込まれていないタブをドラッグしようとするとドラッグできない。(最初の問題)
  • それ以後、サイドバー内で一切のドラッグ操作が行えなくなる。(2回目以降の問題)

2つの現象は「タブをドラッグできない」「どちらもdragstartイベントだけが発行される」という症状は共通していますが、原因が同じかどうかは分かりません。ただ、再現が容易なのは2つ目の現象の方(1つ目の現象が再現したら、以後は何度やっても2つ目の現象が起こる事になるので)なので、先にこちらの方から原因を調べる事にしました。こちらの原因が分かれば、今度はそれが「その原因となっている状況を引き起こした犯人=1つ目の現象の原因」を特定する手がかりとなります。

低レベルのログを収集する

Firefoxには環境変数を指定する事でモジュール単位の低レベルのログを収集する機能があるため、成功ケースと失敗ケースの両方についてこのモジュールのログを取得して比較すれば、何か分かるかも知れません。この方法であれば、子プロセスが出力したログもそれぞれ個別に収集する事ができます。

低レベルのログを収集するには、macOSではターミナルを使います。Firefoxのアプリケーションファイルはファイルシステム上はフォルダ(ディレクトリ)になっているので、以下のようにして環境変数を設定して実行します。

$ MOZ_LOG=timestamp,sync,nsCocoaWidget:5 MOZ_LOG_FILE=~/debug.log /Applications/Nightly.app/Contents/MacOS/firefox

環境変数MOZ_LOGには、ログを取得したいモジュール名とログレベルを:で繋げたものを、,区切りのリストとして指定します。このようにしてFirefoxを起動すると、メインプロセスのログがMOZ_LOG_FILEで指定した名前のファイルに出力され、子プロセスのログはdebug.log.child-1のような名前で同じ位置に出力されます。ログ出力が始まった状態から、ターミナルの新しいタブでtail -F debug.log*と実行すれば、それぞれのログに出力される内容をリアルタイムで見る事もできます。

しかし、先程特定した「成功時と失敗で共通して呼ばれているモジュール」であったEventStateManager.cppのソース内をlogで検索してみても、ログ出力を行っているらしきコードは見つかりませんでした。また、dragoverイベントが発行される契機になっている処理の方についてはnsCocoaWidgetというログモジュールでログを収集できましたが、こちらは成功ケースのログしか出力されず、しかも知りたい核心部分の処理が全て終わった後の時点のログでした。これではログを取っても調査の役には立ちません。

モジュール単位でのログが役に立たないとなると、何か別の方法で「成功ケースと失敗ケースのそれぞれで、内部的に何が起こっているのか」を調べる必要があります。

デバッグビルドとデバッガを使用した調査

このような場面で使えるツールの1つがデバッガです。問題が発生するケース・期待通りに動くケースで必ずこの行を通る、という事がはっきり分かっている場合には、デバッガ上でそこにブレークポイントを仕掛けておく事で、処理を一時停止して各変数の値を詳細に見る事ができます。また、そこから1行ずつ処理を進めて流れをじっくり追うという事(ステップ実行)もできます。

デバッグビルドの準備

ただ、デバッガを使うにはmacOS版Firefoxのデバッグ用ビルドが必要です。オフィシャルに公開されているバイナリは無い様子でしたので、「Firefox macOS debug」と検索して出てきたMDNのmacOSでのデバッグ手順の解説ビルド手順の解説を見ながら自分でデバッグビルドを作成することにしました。

  • macOS版FirefoxのビルドのためにはXcodeが必要です。しかし、調査に使える環境はmacOS 10.12 Sierraだったため、App Storeにある最新のmacOS向けのXcodeはインストールできませんでした。そこで「Xcode for old macOS」などのキーワードで検索して見つかった情報を参考に、開発者向けのページからXcodeの旧バージョンをダウンロードしてインストールしました。
  • Homebrewなどのツール類については、解説にある通りbootstrap.pyを使って一括インストールする事ができました。
    • 初期設定時に選択するビルド対象は、Artifact Buildではないデスクトップ版Firefoxとしました。Artifact Buildとは、ビルド済みバイナリをダウンロードしてきて使う事によって、ビルドに要する時間を短縮できるという特殊なビルドです。しかし、今回はバイナリ部分をデバッグ用にビルドしたいので、Artifact Buildは不適当という事になります。
  • 既にNightlyで現象を再現できていたので、cloneするリポジトリはNightlyに対応する https://hg.mozilla.org/mozilla-central/ を使いました。

解説の通りに進めてデバッグビルドができた時点で*3./mach runでテスト実行してみました。最適化がなされないためなのかデバッグビルドだからなのか、一挙手一投足がもたつく程に非常に低速ではありますが、一応動く事は動いているため、準備が整った事にして次の行程に進みます。

ブレークポイントの設定とステップ実行

macOSでのデバッグ手順解説に記載の通りに準備した上で、Xcodeの「Product」メニューから「Run」を選ぶと、デバッグビルドのFirefoxが起動します。

(Xcodeでブレークポイントを設定した様子)

ブレークポイントを設定するためには、まずXcodeの左ペインのフォルダーアイコンをクリックし、ソースコードのファイル一覧から「ここで処理を止めたい」という処理が含まれているファイル(今回は dom/events/EventStateManager.cpp )を選択します。 右上のペインにソースコードが表示されますので、止めたい処理の行番号をクリックします。すると、行番号の部分に青いマークが付きます。これをブレークポイントといい、Xcode経由で起動したデバッグビルドのFirefoxの内部で処理がこの行に到達すると、処理がその場所で止まってXcodeに制御が移るようになります。

ブレークポイントで処理が止まった後は、左のペインの内容が関数呼び出しのスタック表示に切り替わり、ソースコードが表示されているペインの左下ペインには停止位置での各変数の値が表示されます。

(ブレークポイントで処理が停止した様子)

この時には、ツールバー上の各ボタンで以下の操作を行えます。

  • Deactivate breakpoints:全てのブレークポイントを一時的に無効化する。もう一度クリックすると、ブレークポイントを有効化する。
  • Continue program execution:次のブレークポイントまで処理を一気に進める(ブレークポイントでの一時停止を解いて、通常の実行に戻す)
  • Step over:処理をソースコード上で1行分次に進める(ステップ実行)。
  • Step into:関数の呼び出し行において、関数の中に入る(呼ばれた関数の1行目からステップ実行を行う)。
  • Step out:関数の外に出る(現在ステップ実行中の処理を関数の最終行またはreturnに到達するまで進めて、呼び出し元の関数で元の関数が呼ばれている行からステップ実行を行う)。

変数の値を調べても有用な情報を見付けられなかった場合には、ステップ実行を繰り返したり、別の位置にブレークポイントを設定したり、ブレークポイントを削除*4したりして、解析を続けていきます。同じ箇所で「期待通りの結果が得られている時の内部状態」と「問題が起こっている時の内部状態」を詳細に比較していけば、「何が原因で、処理が期待と異なる方向に進んでいってしまったのか」を明らかにできるというわけです。

ただ、実際には各変数の値はツリー構造になっていて、奥の奥の方に原因が潜んでいるという場合も多々あるため、余程「ここをピンポイントで調べたい」という事が事前にはっきり分かっている場合でもない限りは、この方法で一発で原因を見付けるというのは難しいです。今回の調査でも、闇雲に調べるだけでは残念ながら決定打となる情報に辿り着く事はできませんでした。

期待外れだったのは、成功ケースで実際にdragoverイベントが発行されている場面の詳細を調べられなかったという点です。この処理はCocoaに対して登録しておくイベントハンドラのような関数の中にあり、Cocoaから通知されたイベントをトリガーに実行されるため、ここにブレークポイントを置いても(関数呼び出しのスタックが切れてしまっているので)デバッガでは処理の呼び出し元を辿る事ができないのです。「成功ケースと失敗ケースで明らかに異なる部分」が目の前にあるにも関わらず、そこからは何の情報も得られないという、非常に残念な結末でした。

ただ、ここまでの調査過程で以下の事は分かりました。

(プロセス間でのドラッグデータの受け渡しの様子の図)

  • 成功ケースでは親プロセス側で認識できたドラッグデータ(アドオンで定義したdragstartイベントのリスナにおいて、event.dataTransfer.setData()で追加したデータ)の個数が1以上になっているが、失敗ケースでは0になっている。子プロセス側で設定したはずのドラッグデータが親プロセスからは認識されていない。そのため、EventStateManager::DoDefaultDragStart()falseを返して終了する形となっており、ドラッグセッションを開始するnsDragService::InvokeDragSessionWithImage()が呼ばれていない。
  • このドラッグデータはEventStateManager::DetermineDragTargetAndDefaultData()nsContentAreaDragDrop::GetDragData()DragDataProducer::Produce()TabParent::AddInitialDnDDataTo()TabParentmInitialDataTransferItemsというメンバ変数の値を取得する形で初期化されている。
  • TabParentmInitialDataTransferItemsの値は、TabParent::RecvInvokeDragSession()で設定されている。このメソッドは、プロセス間通信でPBrowser::Msg_InvokeDragSession__IDというメッセージを受け取った時に実行されている
    • この時のIPC周りのコードは自動生成されているため、デバッガでブレークポイントを設定できない。
  • PBrowser::Msg_InvokeDragSession__IDというメッセージは、子プロセス側でnsDragServiceProxy::InvokeDragSessionImpl()が呼ばれた時に(IPC周りの自動生成されたコードを経由して)親プロセスに向けて送出される模様。

以上の通り、成功するケースでは子プロセス側のInvokeDragSessionと親プロセス側のInvokeDragSessionが両方とも期待通りに処理されるのに対し、失敗するケースでは親プロセス側のInvokeDragSessionに到達する前に(ドラッグデータが0個という事で)処理が終わってしまっている、という事が分かりました。という事は、失敗ケースでは「子プロセスが送ったメッセージを何らかの理由で親プロセスが受け取れていない」か、もしくは「子プロセスがそもそもそのメッセージを送出していない」かのどちらかであるという事が言えます。

標準出力・標準エラー出力に現れるメッセージの監視

状況の把握のためにPBrowser::Msg_InvokeDragSession__IDというメッセージやその他のメッセージを親プロセスが受け取れているかどうかを調べたいと思ってコードを見ていると、TabParent::RecvInvokeDragSession()呼び出し元箇所mozilla::ipc::LogMessageForProtocol("PBrowserParent",...というコードがある事に気付きました。関数の定義を調べてみた所、これはデバッグビルドの実行時にMOZ_IPC_MESSAGE_LOGという環境変数にPBrowserParentまたは1が設定されている時に、fputsで標準エラー出力にログメッセージを出力するという物であることが分かりました。このログメッセージが現れるかどうかを調べれば、上記のIPCのメッセージを親プロセスが受け取っているかどうかが分かります。

fputsで直接標準出力や標準エラー出力に出力されたメッセージは、MOZ_LOGで収集するログには出力されません。ではどこを見ればよいかというと、Xcodeの右下のペインです。標準出力や標準エラー出力に出力されたメッセージは、ここで確認する事ができます。

デバッグ実行時の環境変数は、Xcodeのメニューの「Product」→「Scheme」→「Edit Scheme」→「Run」→「Environment Variables」で設定できます。ここでMOZ_IPC_MESSAGE_LOGPBrowserParentを設定して再度デバッグ実行して確認した所、成功ケースでは上記のログメッセージが出るのに対し失敗ケースでは出なかったという事から、親プロセス側がPBrowser::Msg_InvokeDragSession__IDというメッセージを受け取れていないという事が分かりました。

そうなると今度は、子プロセス側がIPCのメッセージを送出しているにも関わらず親プロセスが受け取れていない(IPCの仕組みの中での問題)のか、それとも子プロセス側でnsBaseDragService::InvokeDragSession()が実行されていないかのどちらかという点が問題になります。

任意のログを任意のタイミングで出力させる

ここで子プロセス側の処理に対してデバッガでブレークポイントを設定できればよかったのですが、弊社調査環境では何故か、MDNのマルチプロセス有効時のデバッグに関する説明通りに設定しているにも関わらず、設定したブレークポイントで処理を止める事ができないという状況でした。処理の流れを追いたい肝心のモジュールは前述した通りMOZ_LOGでの指定でログを出力してくれないため、これでは調査のしようがありません。

そのため、ここで初めてカスタムログに頼る事にしました。

MOZ_LOGで出力されるログは既存の物以外に、全く新しくログを出力する事もできます。C++製コンポーネントの開発に関わる人向けにMOZ_LOGで出力可能なログをC++製のモジュール内で定義する手順の説明が用意されていますので、これを参考に、EventStateManager.cppの冒頭で以下のようなログモジュールを定義するようにしました。

using mozilla::LogLevel;
static mozilla::LazyLogModule sEventStateManagerLog("EventStateManager");

その上で、ここまでに調査した範囲の中で特定していたドラッグを開始するかどうかの判定を何段階も行っている関数において、returnする直前にMOZ_LOG(sEventStateManagerLog, LogLevel::Info, ("DoDefaultDragStart - no drag service"));のような行を追加して、ログを見ればどのreturnで関数が終了したのかを分かるようにしました。

このようにして収集したログを調べて分かったのは、2回目以降の失敗ケースにおいては「現在進行中のドラッグセッションがまだあるならば、新たなドラッグセッションは開始しない」という判断の結果親プロセス側でドラッグセッションが開始されていない、という事でした。

つまり、1回目の失敗ケースから2回目以降の失敗ケースにかけての間では以下の事が起こっていたと考えられます。

  • 成功ケースや1回目の失敗ケースでは、子プロセス側では通常通りにドラッグセッションが開始されている。
  • その処理が親プロセスに引き継がれるまでの処理のどこかに問題があって、「子プロセスはドラッグセッションが開始されているが、親プロセスはドラッグセッションが始まっていない(終了した)と認識している」状態が発生した。
  • 本来であれば子プロセス側のドラッグセッションを終了させるための処理が行われるはずなのに、この一連の処理がどこかの時点で中断されてしまったために、子プロセス側のドラッグセッショが終了処理が呼ばれず、ゾンビドラッグセッションが残留してしまっている。
  • このゾンビドラッグセッションがあるせいで、親プロセスでドラッグセッションが開始されなくなっている。

ここまでで分かった事をまとめて、1回目の失敗ケースの原因を探る

ここまでの調査で、2回目以降の失敗ケースは「1回目の失敗ケースにおいて、子プロセス側のドラッグセッションが開始されたにも関わらず、親プロセス側ではドラッグセッションが開始されなかったために、子プロセス側で残ってしまったゾンビドラッグセッション」が原因で発生している事が分かりました。

そうなると今度は、1回目の失敗ケースで何故ゾンビドラッグセッションができてしてしまうのか、何故親プロセス側でドラッグセッションを開始できなかったのか(処理が中断されたのか)、という事を明らかにしなくてはいけません。

現時点までで、1回目の失敗ケースでは子プロセスから親プロセスへドラッグデータがきちんと引き渡されているという事が分かっています。そうなると、「ドラッグデータは受け取れているが、ドラッグセッションを開始できない」という状況が発生する条件は、一体何なのでしょうか?

以上の事を念頭に置きながら関連モジュールのコードを眺めていると、各プラットフォームで共通のドラッグセッション開始のための処理であるnsBaseDragService::InvokeDragSession()から呼び出されるmacOS版固有のnsDragService::InvokeDragSessionImpl()の実装の冒頭に、何らかの条件が満たされなかった時にNS_ERROR_FAILUREというエラーコードを返却するというコードがある事に気がつきました。

さらにその先を読み進めていくとbeginDraggingSessionWithItemsというCocoaのAPIを呼んでいる箇所があります。調べてみると、beginDraggingSessionWithItemsはまさにドラッグセッションを開始するためのCocoaのAPIであると書かれています。

ここまで分かってやっと、失敗ケースでdragoverイベントを発行するためのコードが何故呼ばれないかの謎が解けました。このコードはCocoaでのドラッグセッションが進行中である場合に呼ばれるdraggingUpdateというイベントハンドラの中にありますが、ドラッグセッションが開始されていなければ当然これらのイベントハンドラも呼ばれないという訳です。つまり最初から、beginDraggingSessionWithItemsの呼び出しに至るまでのコードパスを調査すればよかったのでした。Cocoa APIの知識を持たない状態で調査を始めたために、ひどく遠回りをしてしまった事になります。

ともあれ、これで調査が必要な範囲はだいぶ絞り込めました。今度はbeginDraggingSessionWithItemsが呼ばれなくなるパターンの分岐に絞ってMOZ_LOGを仕込んで再びログを収集してみた所、1回目の失敗ケースはまさに先程見付けたNS_ERROR_FAILUREを返す分岐に入っている、という事が分かりました。

その分岐に入る条件はgLastDragViewというグローバル変数が空である事で、これは意味としては、「どのビューでドラッグが開始されたか分からなければ、ドラッグセッションを開始せずにエラーを返す」という事です。

そこでこの変数に値を代入している箇所を検索した所、変数の初期化時を除くと、Cocoa API用のmouseDraggedというイベントハンドラの中でのみ値を設定している事が分かりました。具体的には、4728行目で「そのビュー自身」を代入し、イベントの処理を挟んだわずか10行後にnilを再代入しているという状況でした。そこでこの前後に絞ってMOZ_LOGをさらに仕込んで調査した所、成功ケースと失敗ケースでは非同期処理の実行順が異なっており、そのせいで失敗ケースでは、ドラッグが開始されたビューが分かるより前にドラッグセッションを開始しようとしてドラッグセッションを開始できずにいるらしいという事が分かりました。

詳しい人の意見を聞いてみる

Cocoa APIまわりの非同期処理の話になると、ますますこちらに知見がないため、これ以上の調査は難しく思えます。そこで一旦、識者に意見を求めてみる事にしました。

調査対象にしているファイルのコミット履歴を見ると、複数人が関与している様子が窺えます。この時に注目するのは、コミットした人やパッチを書いた人ではなく、パッチのレビューを行った人です。

パッチが投入されるまでの流れを見ると分かりますが、パッチのコミット自体はcheckin-neededという目印に沿って作業担当者が行っているだけなので、コミットした人は必ずしもそのモジュールのエキスパートという訳ではありません。また、パッチを書いた人もたまたま関わっただけの協力者という立場である可能性があります。それらに比べると、レビューはある程度の知見がないとできないため、複数のパッチでレビュー担当者として指名されている人ほど、信頼できる識者である可能性が高くなります。

Bugzillaではコメントを追記する時に「needinfo」という情報を設定できます。これはバグの報告者など他の人に情報を求める、つまり質問するときに使われる機能です。「Need more information from...」というラベルのチェックボックスをONにして、質問先の種別を「other」にし、上記の方法であたりを付けた識者の人のメールアドレスを入力して投稿すれば、これで「質問されている」という事がその人に通知されます。

数日待った所、その方が反応して下さり、「それぞれのケースのスタックトレースを収集してみてはどうか」というようなコメントを頂けました。

スタックトレースはXcodeのメイン画面の左ペインに表示されますが、これをそのままコピーする事はできません。文字列としてコピーできる形でスタックトレースを取得するには、ブレークポイントで処理を止めた状態でXcodeのウィンドウ右下のペインのコンソールの「(lldb)」と表示されている箇所(これが実はプロンプトになっています)にbtというコマンドを入力して実行すると、コンソール内にスタックトレースの情報が出力されます。これを選択してコピー&ペーストすれば、スタックトレースを容易に収集できます。

この方法でスタックトレースを収集してじっくり比較してみた所、成功ケースではCocoa APIのmouseDraggedハンドラからそのまま呼び出しが続いているのに対し、失敗ケースではそうなっておらず、RefreshDriverTimer::Tickなどのメソッドの呼び出しが親となっていたという事が分かりました。

(1つのイベントループの中で処理が完結する場合は成功する) (1つのイベントループで処理が完結しなかった場合は失敗する)

成功ケースでも失敗ケースでもmouseDraggedから全ての処理が始まっているとばかり思い込んでいたため、これは盲点でした。

どうやら、何らかの条件に合致した場合には、ドラッグ時のイベントの処理が実際には後のイベントループに回されており、その場合は必要な情報であるgLastDragViewが既に失われてしまっているのでドラッグセッションを開始できない、という事がこの問題の根本的な発生原因である模様です。このような状況は、元々同期処理で書かれていた物を後から非同期処理の形に改修したというような場面でよく見られる物です。Firefoxは現在、体感的なパフォーマンスの向上のための改良が続いていますので、その中でそういった事が起こる事は十分にあり得そうです。

パッチの作成

ともかく、原因が分かったことでようやく問題を修正する段階に移れます。

ここまでの調査で分かった原因からは、以下のような改修案が考えられます。

  1. gLastDragViewをすぐにnilで破棄してしまわずに、ドラッグイベントの処理が終了するまで待ってから破棄するようにする。
  2. gLastDragViewに相当する情報をイベントの情報の一部として引き回す、または保持しておき、後のイベントループで続きが処理される時にその情報を参照するようにする。

1の方法は非常に単純なやり方です。確実にこの問題は直りますが、gLastDragViewがメモリ上に保持される期間が長くなるため、メモリリークや、ドラッグ開始操作として認識されて欲しくない物が誤ってドラッグ開始操作として扱われてしまうといった新しい別の問題の原因になる恐れがあります。

2は、そういった副作用の恐れがない安全な方法に思えます。一般的にはこちらの方針で改修を行う事が望ましいと言えるでしょう。ただ、今回はこの方針を取るのがためらわれる理由があります。それは、gLastDragViewを設定している箇所や参照している箇所がmacOS固有の実装の中であるのに対し、設定箇所から参照箇所までの間に通過する処理はほとんど全てクロスプラットフォームな実装であるという点です。クロスプラットフォームな実装の中に特定プラットフォーム向けのコードを入れるという変更はあまり行儀が良いとは言えず、また、その中には非同期処理の基盤的な実装も含まれていたため、変更の影響が想像以上に広い範囲に及ぶ恐れがあります。副作用の恐れがないはずの方法の方が、実際にはリスクが大きいという困った状況です。

以上の検討結果を踏まえ、1の方が調査が必要な範囲は狭い(macOS固有の実装だけに変更が閉じる)と考えられたため、まずはその前提で影響範囲を調査・検討しました。その結果、想定外のタイミングでドラッグセッションの開始処理が呼ばれてしまう事は理論上あり得ないという結論に至ったため、実際に1の方針でパッチを作成しました。しかし安全であると言い切れる確証は持てなかったため、前のコメントで紹介して頂けた別のこの件に詳しい方にneedinfoで意見を求めてみる事にしました。

時間はかかったものの無事にその方にパッチを見て頂けて、方針はこれで問題ないというコメントと、追加で修正が必要な箇所(コメント形式で書かれた説明文が実装と食い違うようになるため、それらも併せて更新する必要がある)の指摘を頂きました。現在は、その指摘に基づいて更新したパッチを再提出してレビューを待っているという状況です。この後特に問題がなければ、パッチが取り込まれるまでの流れなどで紹介ている通りに進行してマージに至ると期待されます。

まとめ

以上、プロセス間通信やmacOSでのドラッグ&ドロップといった箇所の実装を調査して、バグの原因を特定しパッチを提出するまでの一通りの経緯をご紹介しました。

最終的なパッチは実質的には1行削除・2行追加しただけの内容ですが、そこに辿り着くまでの調査にはかなりの時間を要する結果となってしまいました。要因の1つに、Cocoa APIを使ったmacOSネイティブアプリにおけるドラッグ&ドロップの実装の一般的な作法を知らないまま調査を進めた事がある事は否めず、その部分の調査をスキップする選択をしたという調査初期の判断ミスが悔やまれます。

この記事で述べた情報そのものが直接的に役に立つ場面はあまり無いと思われますが、未知の部分が大きい調査対象に取り組む際の様々なアプローチの仕方や、調査方針の見直しのタイミングなど、メタな部分で知見を得るための資料、あるいは「しくじり先生」的な反面教師として参考にしていただけると、この紆余曲折の記録も無駄にはならないのではないかと思っております。

OSSの利点の1つとして、今回のように、不可解なトラブルに遭遇した場合でも第三者の立場で詳細な調査を実施できる余地があるという点が挙げられます。本体の開発チームは通常のリリースに向けての作業に注力している事が多く、今回の問題のように希な条件下でしか発生しない・一般のユーザーに与える影響の小さい問題の解決はどうしても後回しになってしまいがちです*5。しかし、その問題に遭遇した当事者にとってはまさに今直面している問題で、ともすれば死活問題ともなり得ます。そのような場合でもただ待つだけ以上の事ができるというのは、OSSならではの事と言えるでしょう。

当社は、そのようにOSS開発により積極的に関われるようになりたい人を支援するOSS Gateという取り組みを支援しています。このエントリを見て「普段使っているOSSに自分でもフィードバックできるのか! 自分もやってみたい!」と新鮮な驚きを感じた方や、「そうそう、こんな感じで調査するんだよね。このやり方が分からなくて困ってる人を手助けできればいいんだけど……」とお考えの方は、ぜひワークショップへの参加をご検討下さい。

また、当社ではFirefoxやThunderbirdの他、Fluentd、Groonga等のOSSの法人利用において発生する様々なトラブルや不具合について有償でのサポートサービスを提供しており、このエントリに記載しているようなソースコードレベルでの調査も承っております。業務上でのOSSの利用でお困りの場合、お問い合わせフォームよりお問い合わせ下さい。

*1 そこで学ぶ知識を今後も活用していける目処があるなら話は別ですが……

*2 ただ、こちらはCocoa(macOSでGUIアプリを実装する際に使われるAPIセット)のイベントハンドラにあたる関数で、単にCocoaでの`draggingUpdate`というイベントを`dragover`というDOMイベントにマッピングするためのものでしかありません。後述しますが、この事が後の調査を難航させる1つの原因になりました。

*3 使用した検証機ではフルビルドに4〜5時間程を要しました。

*4 ブレークポイントを左クリックすると、そのブレークポイントだけ一時的に無効化できます。右クリックして「Delete Breakpoint」を選択すると、ブレークポイントを削除できます。

*5 とはいえ、今回取り組んだBugはpriorityがP3(中程度)と設定されており、それなりに重要な問題と認識されてはいたようです。

2018-10-17

グループポリシーでエンタープライズの証明書を配布した場合、IE(Edge)では閲覧できるページがFirefoxではエラーで閲覧できない場合があります

Firefox ESR52以降のバージョンは、security.enterprise_roots.enabledtrueに設定しておく事で、Active Directoryのグループポリシー機能を使って配布された証明書をFirefoxに自動的にインポートできます *1。また、Firefox ESR60ではグループポリシー経由である程度の設定の制御も行えるようになりました。以前のバージョンのFirefoxでは証明書のインポートや管理者による設定の制御にはCCK2や独自形式の設定ファイルなどを別途用意する必要がありましたが、Firefox ESR52以降では設定も証明書の管理もActive Directoryのグループポリシーに一元化できるようになったという事で、Internet Explorer(およびその後継ブラウザであるEdge)をメインで運用しつつFirefoxも併用するという使い方をしやすくなってきていると言えます。

ただ、そのために、「IEではこれで問題なく閲覧できるのに、Firefoxでは何故かエラーになる」という状況が顕在化しやすくなっています。組織内のWebサイトにSSL/TLSで接続できるようにするために証明書をグループポリシーで配布した場合に、IE(Edge)では期待通りにそのWebサイトを閲覧できるのにも関わらず、Firefoxでは何故か証明書のエラーになって閲覧できない場合がある、というのもその一つです。

この現象が発生する原因として典型的なのは、配布されている証明書自体に問題があるというものです。

証明書の安全性がFirefoxの求める要件を満たしていない場合

Firefoxをアップデートしたら急に、今まで問題なく閲覧できていたWebサイトで「安全でない接続」というエラーが表示されるようになり、閲覧できなくなってしまった。という場合、これはFirefoxの許容する証明書の安全性の水準が見直された事が原因となっている可能性があります

「この水準の安全性が確保されていれば妥当な証明書として認める」という基準は、IE/EdgeとFirefoxで異なります。また同じFirefoxであっても、バージョンによってその基準が見直される事があります。Firefoxのアップデート直後からSSL/TLSに関するトラブルが急増したという場合には、Firefoxの各バージョンのリリースノートや、開発者向けリリースノートの「セキュリティ」の見出し配下を確認し、問題が起こるようになったバージョンにおいて何らかのアナウンスがなされていないかを確認してみて下さい。

GoogleやYahooなどの一般的なWebサイトを閲覧しようとして「安全でない」というエラーが表示されるようになったというケースも、証明書の安全性が低い事が原因である場合が多いです。これは、以下の例のような「SSLロガー」や「SSLプロキシ」と言われるようなネットワーク機器やサービスを使用している場合によく見られるトラブルです。

この種の機器やサービスは、使用にあたっては原理上必ず、各クライアントに専用の認証局証明書をインポートする必要があります。この認証局証明書がFirefoxの求める安全性の水準を満たさなくなると、SSL/TLSを使用したあらゆるWebサイトで「安全でない接続」の問題が起こるようになります。 (いわゆるSSLプロキシがある場合の通信) 導入手順の一環として流されがちなためか、「証明書をインポートして使っている」という事実を忘れてしまいやすい模様ですので、殊更注意が必要です。

認証局証明書として本来は不正な証明書を、気付かずに使っている場合

証明書の安全性の水準に問題がなくても「安全でない接続」のエラーが発生する場面がもう1つあります。それは、認証局証明書(CA*2証明書、あるいはルート証明書)として使えないはずの証明書をエンタープライズの証明書として配布してしまっている場合です。

具体的には、実験や組織内での一時的な使用のために作られる事の多い、「サーバーの署名用」ではあるが「認証局証明所用」ではない自己署名証明書をエンタープライズの証明書として配布している場合に問題が起こります。実際に、各種の証明書エラーの様子を確認できるbadssl.comというサイトにおいて自己署名証明書を使ったサイトの例で使われている証明書の詳細な情報を見ると、

  • Subject(この証明書の識別子と証明する対象):C=US, ST=California, L=San Francisco, O=BadSSL, CN=*.badssl.com
  • Issuer(この証明書の署名者):C=US, ST=California, L=San Francisco, O=BadSSL, CN=*.badssl.com
  • X509v3 extensions(拡張属性), X509v3 Basic Constraints(基本の制約事項):CA:FALSE(認証局ではない

と、まさにそのような形式になっています。

このような証明書を使っているWebサイトを訪問した場合には、必ず「安全でない接続」のエラーが表示され、内容を閲覧するためにはそのサイトをセキュリティの例外として登録しなくてはなりません。しかしながら一般的に、セキュリティの例外はユーザー個々人が手動で操作して行う必要があります。組織内で多くのユーザーが共通して使用するWebサーバーでこの種の証明書を使っている場合*3、全ユーザーに手動で例外登録をさせるというのは非現実的です。そこで取られる事があるのが、この証明書自体をエンタープライズの証明書として配布するという方法です。

この証明書がエンタープライズの証明書として読み込まれているクライアントPCでは、IE/Edgeで当該Webサイトを訪問すると、

  1. そのWebサイトが送ってくる証明書の署名者を確認する。
  2. エンタープライズの証明書に登録されている証明書(Webサイトが送ってきた証明書と同じ物)が見つかる。
  3. 見つかった証明書が認証局証明書として使われ、Webサイトが送ってきた証明書が正しく認証局証明書によって発行された物であると判定される。

という形で検証され、安全な接続であるとしてWebサイトのコンテンツがそのまま表示される結果になります。

Firefoxでsecurity.enterprise_roots.enabledtrueに設定されていている場合も、当然IE/Edgeと同様の検証が行われて当該Webサイトの内容が警告無しに表示できるはず……と思ってしまう所ですが、実際にはそうはなりません。Firefoxでは、

  1. そのWebサイトが送ってくる証明書の署名者を確認する。
  2. エンタープライズの証明書に登録されている証明書(Webサイトが送ってきた証明書と同じ物)が見つかる。
  3. 見つかった証明書にCA:TRUE(認証局証明書であるというメタ情報)が無いため、認証局証明書ではないと判定される。
  4. 1の証明書の署名者の認証局証明書が見つからないため、証明書の妥当性を検証できず、エラーになる。

という形で検証が行われるため、安全な接続ではないと判定され、Webサイトのコンテンツの代わりにエラーページが表示される結果になります*4

ユーザーが手動操作でFirefoxの証明書マネージャから認証局証明書をインポートする場合、本来であれば、用途が認証局証明書でない証明書は「この証明書は認証局の証明書ではないため、認証局の一覧には追加できません。」というエラーメッセージが出て拒絶されるため、誤って認証局に登録してしまうという事も起こりません。しかしながら、グループポリシー経由でエンタープライズの証明書として配布された物を security.enterprise_roots.enabled の機能でインポートする場合には、このチェックが行われません。また、厄介な事に、そうしてエンタープライズの証明書として一度認識されてしまうと、この証明書を証明書マネージャから手動操作で認証局証明書として再インポートしようとした場合にも、用途のチェックがスキップされてインポートに成功してしまうという性質があります。

この問題がそもそも発生しないようにするためには、以下のどちらかの方法を取るしかありません。

  • サーバーの証明書を再発行する。その時に、認証局証明書としての用途も併せて設定することで、認証局証明書で、且つサーバーの証明書という状態にしておく。
  • 認証局証明書を新たに作成し、その認証局で署名された証明書としてサーバーの証明書を再発行する。エンタープライズの証明書としては、認証局証明書の方を配布する。

一般的な認証局で発行してもらう証明書には適切な用途情報が設定されていますが、組織内でのみ使用するために独自に証明書を発行する場合、この点のチェックがなおざりになりがちです。証明書の用途は適切に使い分けるように気をつけましょう。

まとめ

以上、Active Directoryのグループポリシー機能を使って配布したエンタープライズの証明書をIE/EdgeとFirefoxで共用する場合に起こりがちなトラブルについて解説しました。

当社では、Firefoxの法人での運用における不明点の調査、ご要望に合わせた適切な設定のご案内、本体に含まれない特別な運用を実現するアドオンの開発などを広く承っております。Firefoxの運用でお困りのシステム管理・運用ご担当者さまは、お問い合わせフォームよりお気軽にお問い合わせ下さい。

*1 なお、この機能はActive Directoryを運用していない環境でも、Windowsのレジストリを編集することで動作の検証が可能です

*2 Certificate Authority=認証局

*3 本来であればそのような運用を取るべきではありません。

*4 これはIE/EdgeとFirefoxで証明書の取り扱いのポリシーの厳格さに差があるためで、どちらかが正しくてどちらかが間違っているという事はありません。IE/Edgeは証明書の用途に関する検証がルーズなので、そちらを基準にして運用していると支障が生じる場合がある、という事です。

2018-10-02

YoctoのWeston上で日本語入力

はじめに

これまでにも何度か紹介していますが、クリアコードではGecko(Firefox)を組み込み機器向けに移植する取り組みを行っています。

その後、課題として残っていたWebRTCも無事に動作するようになり、主だった機能は主ターゲットであるRZ/G1M上で動作するようになっています。

今回は趣向を変えて、同環境上で少しだけ日本語入力を検証してみた経過を紹介します。

uimでの日本語入力の様子

Yoctoでの日本語入力事情

Yoctoの主要なレイヤを概観してみたところ、IMフレームワークとしてはuim、日本語変換エンジンとしてはAnthyが見つかりました。

逆に、これ以外のIMフレームワークや日本語変換エンジンのレシピを見つけることはできませんでしたので、今回はこれを使用してみます(uim-skkでも良いと思いますが、一般向けにはやや紹介しづらいので、今回はAnthyのみを対象とします)。

さて、ウィンドウシステムがX11であれば、おそらく上記レシピをそのままビルドするだけで使用できるでしょう。ですが、今回の対象はYoctoのcore-image-westonであり、ウィンドウシステムはWayland/Westonです。uimにWaylandサポートが入ったのは1.8.7からで、上記レシピは1.8.6ですから、恐らくこれを普通にビルドするだけでは日本語入力できるようにはならないでしょう。実際に試してみましたが、やはりアプリケーションがクラッシュして起動できないという結果となりました。

レシピの修正

上記仮説が正しければ、単にuimを最新版に上げるだけでWeston上でも動作するでしょう。ですが、実際にuimのバージョンを上げてビルドを試してみたところ、同レシピに含まれるパッチがそのままでは当たらないなどの問題が発生しました。そちらを修正するのも難しくはないでしょうが、まずは手っ取り早く動くか動かないかを確認したかったため、uim-1.5.6のまま最低限のパッチを最新のuimからバックポートしてみることにしました。以下がmeta-openembeddedに対するパッチです。

commit f3b0e042986a83a767a967ec352c731037693d98
Author: Takuro Ashie <ashie@clear-code.com>
Date:   Fri Aug 17 13:16:21 2018 +0900

    uim: First aid to work with Wayland

diff --git a/meta-oe/recipes-support/uim/uim/0001-Fix-the-problem-that-the-candidate-window-is-not-sho.patch b/meta-oe/recipes-support/uim/uim/0001-Fix-the-problem-that-the-candidate-window-is-not-sho.patch
new file mode 100644
index 0000000..6ebeb21
--- /dev/null
+++ b/meta-oe/recipes-support/uim/uim/0001-Fix-the-problem-that-the-candidate-window-is-not-sho.patch
@@ -0,0 +1,26 @@
+From f266ff2b59bc3b0cd732c62683a1df9672114c1d Mon Sep 17 00:00:00 2001
+From: Konosuke Watanabe <konosuke@media.mit.edu>
+Date: Sat, 20 Feb 2016 12:30:35 +0900
+Subject: [PATCH] Fix the problem that the candidate window is not shown in
+ GTK3 environment.
+
+---
+ gtk2/immodule/uim-cand-win-gtk.c | 2 +-
+ 1 file changed, 1 insertion(+), 1 deletion(-)
+
+diff --git a/gtk2/immodule/uim-cand-win-gtk.c b/gtk2/immodule/uim-cand-win-gtk.c
+index 1bfe759c..41590d06 100644
+--- a/gtk2/immodule/uim-cand-win-gtk.c
++++ b/gtk2/immodule/uim-cand-win-gtk.c
+@@ -225,7 +225,7 @@ uim_cand_win_gtk_init (UIMCandWinGtk *cwin)
+ 
+   gtk_widget_set_size_request(cwin->num_label, DEFAULT_MIN_WINDOW_WIDTH, -1);
+   gtk_window_set_default_size(GTK_WINDOW(cwin), DEFAULT_MIN_WINDOW_WIDTH, -1);
+-  gtk_window_set_resizable(GTK_WINDOW(cwin), FALSE);
++  gtk_window_set_resizable(GTK_WINDOW(cwin), TRUE);
+ }
+ 
+ static void
+-- 
+2.17.1
+
diff --git a/meta-oe/recipes-support/uim/uim/0001-gtk3-support-Wayland-backend.patch b/meta-oe/recipes-support/uim/uim/0001-gtk3-support-Wayland-backend.patch
new file mode 100644
index 0000000..e40caeb
--- /dev/null
+++ b/meta-oe/recipes-support/uim/uim/0001-gtk3-support-Wayland-backend.patch
@@ -0,0 +1,71 @@
+From 06558e571967f3cb989bdb550d1dea05247cc21d Mon Sep 17 00:00:00 2001
+From: Kouhei Sutou <kou@clear-code.com>
+Date: Sat, 30 Dec 2017 21:15:50 +0900
+Subject: [PATCH] gtk3: support Wayland backend
+
+GitHub: fix #71
+
+Debian: 810739
+
+Reported by Thibaut Girka. Thanks!!!
+---
+ gtk2/immodule/gtk-im-uim.c   | 16 ++++++++++++++++
+ gtk2/immodule/key-util-gtk.c |  8 +++++++-
+ 2 files changed, 23 insertions(+), 1 deletion(-)
+
+diff --git a/gtk2/immodule/gtk-im-uim.c b/gtk2/immodule/gtk-im-uim.c
+index ac2918ce..066e5f5b 100644
+--- a/gtk2/immodule/gtk-im-uim.c
++++ b/gtk2/immodule/gtk-im-uim.c
+@@ -535,6 +535,22 @@ layout_candwin(IMUIMContext *uic)
+     gdk_window_get_geometry(uic->win, &x, &y, &width, &height, &depth);
+ #endif
+     gdk_window_get_origin(uic->win, &x, &y);
++    {
++      GtkWindow *window = NULL;
++      GdkWindow *gdk_window = uic->win;
++      while (gdk_window) {
++        gpointer user_data;
++        gdk_window_get_user_data(gdk_window, &user_data);
++        if (user_data && GTK_IS_WINDOW(user_data)) {
++          window = user_data;
++          break;
++        }
++        gdk_window = gdk_window_get_parent(gdk_window);
++      }
++      if (window) {
++        gtk_window_set_transient_for(GTK_WINDOW(uic->cwin), window);
++      }
++    }
+     uim_cand_win_gtk_layout(uic->cwin, x, y, width, height);
+   }
+ }
+diff --git a/gtk2/immodule/key-util-gtk.c b/gtk2/immodule/key-util-gtk.c
+index 27abd834..bd029e73 100644
+--- a/gtk2/immodule/key-util-gtk.c
++++ b/gtk2/immodule/key-util-gtk.c
+@@ -319,6 +319,7 @@ im_uim_init_modifier_keys()
+ #ifdef GDK_WINDOWING_X11
+   int i, k = 0;
+   int min_keycode, max_keycode, keysyms_per_keycode = 0;
++  GdkDisplay *gdk_display;
+   Display *display;
+   GSList *mod1_list, *mod2_list, *mod3_list, *mod4_list, *mod5_list; 
+   XModifierKeymap *map;
+@@ -329,7 +330,12 @@ im_uim_init_modifier_keys()
+ 
+   mod1_list = mod2_list = mod3_list = mod4_list = mod5_list = NULL;
+ 
+-  display = GDK_DISPLAY_XDISPLAY(gdk_display_get_default());
++  gdk_display = gdk_display_get_default();
++  if (!GDK_IS_X11_DISPLAY(gdk_display)) {
++    /* TODO: We may need to something for Wayland. */
++    return;
++  }
++  display = GDK_DISPLAY_XDISPLAY(gdk_display);
+   map = XGetModifierMapping(display);
+   XDisplayKeycodes(display, &min_keycode, &max_keycode);
+   sym = XGetKeyboardMapping(display, min_keycode,
+-- 
+2.17.1
+
diff --git a/meta-oe/recipes-support/uim/uim_1.8.6.bb b/meta-oe/recipes-support/uim/uim_1.8.6.bb
index 271718e..e7241f1 100644
--- a/meta-oe/recipes-support/uim/uim_1.8.6.bb
+++ b/meta-oe/recipes-support/uim/uim_1.8.6.bb
@@ -4,17 +4,19 @@ LICENSE = "BSD-3-Clause & LGPLv2+"
 LIC_FILES_CHKSUM = "file://COPYING;md5=32463fd29aa303fb2360faeeae17256b"
 SECTION = "inputmethods"
 
-SRC_URI = "http://uim.googlecode.com/files/uim-${PV}.tar.bz2"
+SRC_URI = "https://github.com/uim/uim/releases/download/uim-${PV}/uim-${PV}.tar.bz2"
 
 SRC_URI_append_class-target = " file://uim-module-manager.patch \
     file://0001-fix-bug-for-cross-compile.patch \
     file://0001-Add-support-for-aarch64.patch \
+    file://0001-gtk3-support-Wayland-backend.patch \
+    file://0001-Fix-the-problem-that-the-candidate-window-is-not-sho.patch \
 "
 SRC_URI[md5sum] = "ecea4c597bab1fd4ba98ea84edcece59"
 SRC_URI[sha256sum] = "7b1ea803c73f3478917166f04f67cce6e45ad7ea5ab6df99b948c17eb1cb235f"
 
 DEPENDS = "anthy fontconfig libxft libxt glib-2.0 ncurses intltool"
-DEPENDS_append_class-target = " intltool-native gtk+ gtk+3 uim-native takao-fonts"
+DEPENDS_append_class-target = " intltool-native gtk+3 uim-native takao-fonts"
 
 RDEPENDS_uim = "libuim0 libedit"
 RDEPENDS_uim-anthy = "takao-fonts anthy libanthy0 glibc-utils glibc-gconv-euc-jp"
@@ -31,6 +33,7 @@ EXTRA_OECONF += "--disable-emacs \
     --without-canna \
     --without-mana \
     --without-eb \
+    --without-gtk2 \
 "
 
 CONFIGUREOPTS_remove_class-target = "--disable-silent-rules"

なお、当時のmeta-openembeddedのレシピではuimのソースコードをダウンロードすることが出来なったので、上記パッチにはその修正も含まれています。この点については既にOpenEmbeddedプロジェクトに報告済みで、修正が取り込まれています

ビルド方法

ベースのブートイメージのビルド方法はこれまでと同様ですので割愛します。 これにuimを追加するには、以下の設定をconf/local.confに追加してcore-image-westonを再作成します。

IMAGE_INSTALL_append = " uim uim-common uim-gtk3 uim-anthy "

動作設定

Anthyを既定のIMとするには、以下の内容で設定ファイル~/.uimを作成します。

(define default-im-name 'anthy)

firefoxを起動する際に、環境変数GTK_IM_MODULE=uimをセットすることでuimが使えるようになります。

$ WAYLAND_DISPLAY=wayland-0 GTK_IM_MODULE=uim firefox

まとめ

YoctoのWeston上での日本語入力について、現在の検証状況を紹介しました。こんな記事を書いている暇があったらとっととアップストリームのuimをバージョンアップしてしまいたいところではありますが、すぐには作業に取りかかれないため、まずは社内Wikiの情報を切り貼りして公開してみました。

つづき: 2018-11-08
2018-08-31

mozregressionを使って、いつFirefoxの機能が壊れたのかを調べる

見つけた不具合をFirefoxにフィードバックする時には、それが後退バグである場合、いつの時点から不具合が発生するようになったのかという情報を書き添えておく事が大事です。この記事では、Firefoxの後退バグの発生時期を割り出す事を支援するツールであるmozregressionの使い方を解説します。

後退バグとは?

後退バグ(regression)とは、今まで正常に動いていた機能が、別の箇所の変更の影響を受けて、意図せず壊れてしまった、というケースを言い表す言葉です。

規模の大きなソフトウェアや複雑なソフトウェアでは、気をつけていても後退バグがどうしても発生してしまいがちです。後退バグが発生した直後にすぐに気付ければいいのですが、普段あまり使わない機能だと、いつからかは分からないが気がついたらその機能がずっと壊れたままだった、という事も起こりえます。

このような場面でよく使われるのが、二分探索という手法です。履歴上の「確実に正常に動作していた時点」と「現在」との中間にあたる時点のコードを調べて、その時点でまだ後退バグが発生していなければそこから「現在」との間の中間を調べ直し、その時点でもう機能が壊れていればそこから「確実に正常に動作していた時点」との中間にあたる時点のコードを調べ直す……という要領で範囲を絞り込んでいく事で、当てずっぽうで調べたり虱潰しに調べたりするよりも遙かに効率よく後退バグの発生時点を割り出す事ができます。

Gitでバージョン管理されているプログラムであればgit bisectというコマンドを使ってそれを行えますし、Mercurialにも同様にhg bisectというコマンドがあります。ただ、Firefoxのように大規模なソフトウェアでは、二分探索でその都度ビルドするというのは現実的ではありませんし、「特定の時点のNightlyのビルド済みバイナリをダウンロードしてきて展開して起動して……」という事を繰り返すのも大変です。そこで登場するのがmozregressionなのです。

mozregression-guiの使い方

Quick Startのページに英語音声の動画での解説がありますが、ここではアドオンのサイドバーパネル上のツールチップの表示がおかしくなる不具合を例に、後退バグの発生時点を割り出してみる事にします*1

mozregressionの配布ページから辿ってGUI版のWindows用ビルドをダウンロードすると、「mozregression-gui.exe」というファイルを入手できます。これはインストーラで、ダブルクリックして実行すると自動的にC:\Program Files (x86)\mozregression-guiへインストールされます。起動用のショートカットは自動的には作成されないため、インストール先フォルダを開いて「mozregression-gui.exe」のショートカットをデスクトップなどに作成しておくと良いでしょう。

二分探索の開始

mozregression-guiを起動すると、3ペインのウィンドウが開かれます。

mozregression-guiのメイン画面

メニューバーの「File」をクリックし、「Run a new bisection(新しく二分探索を始める)」を選択すると、「Bisection wizard」というタイトルのウィザードが開かれて、どのような内容で二分探索を始めるかの設定が始まります。

基本設定の画面

今回は以下のように設定しました。

  • Application(アプリケーションの種類): firefox(この他に「fennec(Android版Firefox)」「Thunderbird」も選択できます)
  • Bits(バイナリの種別): 64(Windows版のバイナリは現在は64bit版が主流なので。32bit版特有の不具合であれば「32」を選択します)
  • Build Type(ビルドの種別):opt(この他に「debug」(詳細なデバッグログを出力できるビルド)と「pgo」(最適化ビルド)も選択できます)
  • Repository(リポジトリ):mozilla-central(この他に「mozilla-beta」などのブランチや「comm-release」などのThunderbird用リポジトリも選択できます)

「Next」ボタンをクリックすると、テスト時のFirefoxの状態を設定する画面になります。

プロファイル設定の画面

特定のプロファイルで起動したときだけ後退バグが再現するという場合、再現に必要なプロファイルのパスを「Profile」欄に入力します。「Profile persistence(プロファイルの永続性)」は初期状態では「clone」になっており、テスト実行のたびに元のプロファイルを複製した物を使い捨てする事になります。「reuse」を選択すると指定したプロファイルをそのまま使う事になります。「clone-first」は両者の中間で、最初のテスト実行時に元のプロファイルを複製した後、以後のテストではそれを再使用します。ただ、新規のプロファイルでも現象を再現できる場合は、「Profile」欄は空にしておくことをお勧めします。

「Custom preferences」には、テスト実行時にあらかじめ設定しておく設定値を記述します。Bug 1474784の当初の報告内容は「extensions.webextensions.remotefalseの時に再現する」という物ですので、そのように設定する事にします。「Add preference」をクリックすると行が追加されますので、「name」欄にextensions.webextensions.remote、「value」欄には"false"と引用符でくくらずにそのままfalseと入力しておきます。

「Custom addons」には、テスト実行時にあらかじめインストールしておくアドオンを登録します。Bug 1474784の当初の報告内容はツリー型タブを使用したときという説明になっているため、アドオンの配布ページの「Firefoxへ追加」ボタンを右クリックしてリンク先のファイル(アドオンのインストールパッケージ)をダウンロードし、mozregression-guiのウィザードの「Add addon」ボタンをクリックしてファイルを選択しておきます。

さらに「Next」ボタンをクリックすると、二分探索を行う範囲を指定する画面になります。

二分探索の範囲の設定

初期状態では、起動した日とその1年前の日の範囲で二分探索を行うように入力されています。「Last known good build」欄には最後に正常に動いていたと確認できているビルドの日付を、「First known bad build」欄には最初に異常に気づいたビルドの日付を入力します。この探索範囲は、狭ければそれだけ効率よく絞り込みを行えます。Bug 1474784は7月の1週目までは起こっていない問題だったので、ここでは2018年7月6日から2018年7月11日までを範囲として入力しました。

探索の範囲を入力したら、準備は完了です。「Finish」ボタンをクリックするとウィザードが終了し、二分探索が始まります。

二分探索が始まると、検証用として先ほど指定した範囲の日付の中からいずれかの日のNightlyビルドがダウンロードされ、新規プロファイルで起動されます。この時には、ウィザードで設定した設定やアドオンが反映された状態になっていますので、後退バグが発生しているかどうかを実際に確かめてみることができます。

二分探索が始まり、後退バグの発生を確認した状態

この例では、このビルドでは後退バグが発生している事を確認できています(スクリーンショット内のNightlyのウィンドウにおいて、サイドバー部分のツールチップが適切にスタイル付けされていない事が見て取れます)。検証用のFirefoxを終了した後、mozregression-guiのメインウィンドウ左上の領域にある「Testing (ブランチ名) build: (日付)」の項目の「good」「bad」の二つのボタンのうち「bad」の方をクリックしましょう。すると再び別のビルドのダウンロードが始まり、ダウンロードが完了するとまたそのビルドが新規プロファイルで起動します。

二分探索中に、後退バグが発生していないビルドに遭遇した状態

最初のビルドに続き2番目のビルドも後退バグが発生していましたが、3番目のビルドでは後退バグは発生していませんでした(スクリーンショット内のNightlyのウィンドウにおいて、サイドバー部分のツールチップが適切にスタイル付けされている事が見て取れます)。このような場合は、mozregression-guiのメインウィンドウ左上の項目の「good」「bad」の二つのボタンのうち「good」の方をクリックしましょう。

このようにして「good」と「bad」を振り分けていくと、やがて、次のビルドが起動されない状態になります。

二分探索が終了した状態

この状態になると、二分探索は終了ということになります。mozregression-guiのメインウィンドウの左上の領域に表示される項目のうち最も下にある緑色の行が「最後の正常ビルド(last good build)」、最も下にある赤色の行が「最初の異常ビルド(first bad build)」を表しており、行をクリックすると右上の領域にそのビルドの詳細が表示されます。この例では、「最後の正常ビルド」は以下の通りでした。

app_name: firefox
build_date: 2018-07-09 14:02:55.353000
build_file: C:\Users\clearcode\.mozilla\mozregression\persist\140937d55bd0--mozilla-inbound--target.zip
build_type: inbound
build_url: https://queue.taskcluster.net/v1/task/eyRSVJsJT4WGMysouGUC_w/runs/0/artifacts/public%2Fbuild%2Ftarget.zip
changeset: 140937d55bd0babaaaebabd11e171d2682a8ae01
pushlog_url: https://hg.mozilla.org/integration/mozilla-inbound/pushloghtml?fromchange=140937d55bd0babaaaebabd11e171d2682a8ae01&tochange=e711420b85f70b765c7c69c80a478250bc886229
repo_name: mozilla-inbound
repo_url: https://hg.mozilla.org/integration/mozilla-inbound
task_id: eyRSVJsJT4WGMysouGUC_w

また、「最初の異常ビルド」は以下の通りでした。

app_name: firefox
build_date: 2018-07-09
build_file: C:\Users\clearcode\.mozilla\mozregression\persist\2018-07-09--mozilla-central--firefox-63.0a1.en-US.win64.zip
build_type: nightly
build_url: https://archive.mozilla.org/pub/firefox/nightly/2018/07/2018-07-09-22-12-47-mozilla-central/firefox-63.0a1.en-US.win64.zip
changeset: 19edc7c22303a37b7b5fea326171288eba17d788
pushlog_url: https://hg.mozilla.org/mozilla-central/pushloghtml?fromchange=ffb7b5015fc331bdc4c5e6ab52b9de669faa8864&tochange=19edc7c22303a37b7b5fea326171288eba17d788
repo_name: mozilla-central
repo_url: https://hg.mozilla.org/mozilla-central

これらの情報をbugの報告に書き添えておくと、実際に修正を行おうとする人の調査の手間が大幅に軽減されます。

なお、最初の異常ビルドの「pushlog_url」欄に現れているURLを開くと、前のビルドからそのビルドまでの間に行われた変更の一覧が現れます。この例では40以上のコミットが一度にマージされた時点から後退バグが発生したという事が読み取れ、後はこの中のどの変更が原因だったかを割り出すという事になります。運がよければ、最初の異常ビルドでの変更が1コミットだけに絞り込める場合もあり、その場合は調査範囲が一気に限定されます。

まとめ

以上、mozregressionを使ってFirefoxの後退バグの発生時点を割り出す手順をご紹介しました。

後退バグの修正にあたっては、いつの時点から機能が壊れていたのか、どの変更の影響で機能が壊れたのか、という事を特定する事が重要です。どの変更でおかしくなったのかが分かれば、原因箇所を特定する大きな手がかりになります。また、新たな別の後退バグを生み出さないためには、後退バグの発生原因となった変更の意図を踏まえつつ対応策を検討する事が有効です。後退バグを報告する場合は、できる限り「どの変更から問題が発生するようになったか」を調べてから報告することが望ましいです。

ただ、このように二分探索で後退バグの発生時点を割り出すためには、各コミット段階で「問題が発生するかどうかを確実に確認できる事」が必須条件となります。ミスが原因で「そもそも起動すらしない」状態のコミットが途中に存在していると、動作を確認できるのは「正常に起動するコミット」の間だけになってしまい、二分探索を有効に行えません。GitHub Flowのようにmasterを常にいつでもリリースできる状態に保ったり、プルリクエストのマージには必ずCIが通る事を条件にしたりといった運用をとる事もセットで行う必要があります。後退バグが発生しても原因をすぐに特定しやすいよう、健全なプロジェクト運営を心がけたいものですね。

*1 このBug自体は実際には既に報告済みの他のBugと同じ原因であったことが分かったため、既にあった方のBugで続きをトラッキングするよう誘導されて閉じられています。

2018-07-18

WebExtensionsによるFirefox用の拡張機能で組み込みのページのローカライズを容易にするライブラリ:l10n.js

(この記事は、Firefoxの従来型アドオン(XULアドオン)の開発経験がある人向けに、WebExtensionsでの拡張機能開発でのノウハウを紹介する物です。)

XULアドオンでは、表示文字列のローカライズには「DTDファイルで<!ENTITY menu.open.label "開く">といった形式でロケールを定義し、XULファイルの中に<label value="&menu.open.label;">のように書いておくと自動的に適切なロケールの内容に置き換わる」「propertiesファイルでmenuLabelOpen=開くといった形式でロケールを定義し、JavaScriptからstringbundle.getString('menuLabelOpen')といった形で参照する(String Bundle)」という2つの方法がありました。

WebExtensionsの国際化対応の仕組みはそれよりももっと単純です。ロケールはJSON形式のみで定義し、browser.i18n.getMessage()でキーを指定すると適切な言語のロケールの内容が文字列として取得できるという物で、XULアドオンでのString Bundleに近い形式です。

この方法はテンプレートエンジンやVirtual DOMなどでUIを構築する場合は特に支障にならないのですが、静的なHTMLファイルで設定画面のページなどを作成する場合には、「参照されるべきロケールのキーを書いておくだけで表示時に適切なロケールの内容が反映される」というXULファイルで使っていた方法のような仕組みが欲しくなる所です。実際、manifest.jsonの中では__MSG_menuLabelOpen__のように書くだけでFirefoxが表示時に自動的に適切なロケールの内容を反映してくれるので、これと同じ事がHTMLファイルではできないというのはもどかしいです。

そこで、静的なHTMLファイルの中にロケールを埋め込む使い方を可能する軽量ライブラリとして、l10n.jsという物を開発しました*1

基本的な使い方

読み込みと初期化

このライブラリを使う事自体には、特別な権限は必要ありません。最も単純な使い方では、国際化するページからファイルを読み込むだけで機能します。

<script type="application/javascript" src="path/to/l10n.js"></script>

このライブラリを使う時は、HTMLのページ中のテキストや属性値に__MSG_(ロケールのキー)__と書いておきます。例えば以下の要領です。

<p title="__MSG_config_enabled_tooltip__">
  <label><input type="checkbox">
         __MSG_config_enabled_label__</label></p>
<p title="__MSG_config_advanced_tooltip__">
  <label><input type="checkbox">
         __MSG_config_advanced__</label></p>
<p title="__MSG_config_attributes_tooltip__">
  <label>__MSG_config_attributes_label_before__
         <input type="text">
         __MSG_config_attributes_label_after__</label></p>

これだけで、ページの読み込み時に自動的に各部分が対応するロケールの内容で置き換わります。

英語での表示 日本語での表示

任意のタイミングでの反映

このライブラリは、動的に挿入されたDOM要素の内容テキストや属性値に対しては作用しません。ページの読み込み完了後に追加された内容に対してもロケールの反映を行いたい場合は、それらがDOMツリー内に組み込まれた後のタイミングでl10n.updateDocument()を実行して下さい。例えば以下の要領です。

var fragment = range.createContextualFragment(`
  <p>__MSG_errorDescription__
     <label><input type="checkbox">__MSG_errorCheckLabel__</label></p>
`);
document.body.appendChild(fragment);
l10n.updateDocument();
明示的に空文字列を使う場合の注意点

「See(リンク)」と「(リンク)を参照して下さい」のように、言語によって要素の前や後に何もテキストを設けない方が自然になる場合があります。このようなケースでは、あらかじめ要素の前後にテキストを埋め込めるようにしておき、言語によってその内容を変えるというやり方が使われる事があります。

<p>__MSG_before_link__
   <a href="...">__MSG_link_text__</a>
   __MSG_after_link__</p>
{ // 英語
  { "before_link": { "message": "For more details, see " } },
  { "link_text":   { "message": "the API document." } },
  { "after_link":  { "message": "" } },
}

{ // 日本語
  { "before_link": { "message": "" } },
  { "link_text":   { "message": "APIドキュメント" } },
  { "after_link":  { "message": "に詳しい情報があります。" } }
}

XULではこのような場合空文字列は空文字列として埋め込まれていましたが、l10n.jsでは対応するロケールが空だった場合は__MSG_after_link__という参照のための文字列がそのままUI上に残ります。これは、browser.i18nにおいて「参照したロケールが未定義だった場合」と「参照したロケールの値が明示的に空文字に設定されていた場合」を区別できないことから、ミスの検出を容易にするために敢えてそのような仕様としているためです。

このようなケースでは、明示的に空にしたい部分には\u200b(ゼロ幅スペース)と書いて下さい。上記の例であれば、訂正後は以下のようになります。

{ // 英語
  { "before_link": { "message": "For more details, see " } },
  { "link_text":   { "message": "the API document." } },
  { "after_link":  { "message": "\u200b" } },
}

{ // 日本語
  { "before_link": { "message": "\u200b" } },
  { "link_text":   { "message": "APIドキュメント" } },
  { "after_link":  { "message": "に詳しい情報があります。" } }
}

まとめ

以上、Firefox用のWebExtensionsベースのアドオンにおける静的なHTMLファイルのローカライズを容易にするライブラリであるl10n.jsの使い方を解説しました。

XULアドオンでの感覚に近い開発を支援する軽量ライブラリは他にもいくつかあります。以下の解説も併せてご覧下さい。

*1 「l10n」は「localization(ローカライズ、地域化)」の略としてよく使われる、先頭と末尾の文字、およびその間の文字数を組み合わせた表現です。ちなみに、同様に「i18n」は「internationalization(国際化)」の略です。

タグ: Mozilla
2018-07-10

WebExtensionsによるFirefox用の拡張機能で設定画面の提供を容易にするライブラリ:Options.js

(この記事は、Firefoxの従来型アドオン(XULアドオン)の開発経験がある人向けに、WebExtensionsでの拡張機能開発でのノウハウを紹介する物です。)

XULアドオンでは、設定UIを提供するのに便利な仕組みが用意されていました。文字入力欄やチェックボックス、ラジオボタンといった入力フィールドに対して、真偽型・整数型・文字列型それぞれの設定を関連付ける事により、設定UIの状態と設定値とが自動的に同期されるため、難しい事を考えなくても良いのが利点でした。標準的なUI要素でできる事の幅は限られていましたが、その制約が同時にガイドラインとなり、ユーザー視点においても、混乱することなく各アドオンの設定を編集できていたと言えるでしょう。

一方、WebExtensionsでは設定UIを提供する標準的な仕組みは用意されておらず、HTMLとJavaScriptの組み合わせで自力でUIを提供しなくてはなりません。そもそも設定値をどのように保存するのかすらも標準的なやり方が定まっておらず、採用した保存先によって設定の変更の監視方法も異なるため、設定値とUIの状態を同期するのも悩み所が多いです。そのためアドオンによって設定画面の作りはまちまちです。XULアドオンのように、何らかのガイドラインに従ってページ(HTML)を記述すれば設定値と状態が適切に同期されてくれる、というような仕組みが欲しくなる人も多いのではないでしょうか。

そこで、Configs.jsを使って表現された設定と併用する事を前提に、一定のガイドラインに則って記述されたHTMLのページと設定値の状態を適切に同期して、「設定UI」としてのページを提供する事を支援する軽量ライブラリとして、Options.jsという物を開発しました。 このライブラリを使うと、以下のような設定画面を比較的簡単に作成できます。

IE View WEでの使用例 Multiple Tab Handlerでの使用例

基本的な使い方

必要な権限

このライブラリを使う事自体には、特別な権限は必要ありません。ただし、設定の読み書きにConfigs.jsを使うため、間接的にstorageの権限が必要となります。

{
  ...
  "permissions": [
    "storage", 
    ...
  ],
  ...
}
読み込みと初期化

このライブラリを使うためには、設定画面を提供するページからファイルを読み込みます。依存ライブラリであるConfigs.jsと併せて読み込む場合は以下のようになります。

<script type="application/javascript" src="path/to/Configs.js"></script>
<script type="application/javascript" src="path/to/Options.js"></script>
<script type="application/javascript" src="init.js"></script>

Options.jsを読み込むと、その名前空間でOptionsというクラスが使えるようになります。初期化処理は、単にOptionsの引数にConfigs.jsのインスタンスを渡してnew Options()とするだけです。例えば以下の要領です。

// init.jsの内容
var configs = new Configs({
  enabled:  true,                  // チェックボックスにする
  urls:     'http://example.com/', // 複数行の文字入力にする
  position: 2,                     // 0, 1, 2のいずれか。ドロップダウンリストで選択する
  theme:    'default'              // 'default', 'light', 'dark'のいずれかをラジオボタンで選択する
});

var options = new Options(configs);

これだけで、設定の読み込みが完了し次第、そのページに書かれたフォーム要素と設定値とが同期するようになります。フォーム要素の状態(チェック状態、入力内容など)を変更すると、その結果は即座にconfigsで定義された設定に反映されます。

設定画面で使えるフォーム要素

Options.jsは、以下の種類のフォーム要素に対応しています。

  • チェックボックス(<input type="checkbox">
  • ラジオボタン(<input type="radio">
  • 文字入力(<input type="text">
  • パスワード入力<input type="password">
  • 数値入力(<input type="number">
  • 非表示(<input type="hidden">
  • ドロップダウンリストによる選択(<select><option>
  • 複数行の文字入力(<textarea>

Options.js自体は基本的にフォーム要素を自動生成する事はなく、利用者が好みの方法で記述したフォーム要素に対して使えるようになっています。基本的には、「configsConfigs.jsのインスタンス)のプロパティ名」と「それと同じidを持つフォーム要素」(ラジオボタンだけは「configsのプロパティ名と同じnameを持つ項目群」)が自動認識され、設定値とフォーム要素のvalueプロパティの値(チェックボックスはcheckedの値、ラジオボタンはvalueが設定値と一致する項目のチェック状態)が同期されます。前述の例のinit.jsで定義している設定に対応するフォーム要素の例を以下に示します。

真偽値の設定に対応するチェックボックスは、以下の要領です。

<p><label><input id="enabled" type="checkbox">機能を有効にする</label></p>

文字列型の設定に対応する入力欄は、以下の要領です。

<p><label>URL:<input type="text" id="urls"></label></p>

<!-- 複数行の入力欄にする場合:
<p><label>URL:<textarea id="urls"></textarea></label></p>
-->

値があらかじめ決められたいくつかの値の中のいずれかになるという選択式の設定に対しては、以下のようにして選択肢を提供できます。

<p><label>表示位置:
     <select id="position">
       <option value="0"></option>
       <option value="1">中央</option>
       <option value="2"></option>
     </select>
   </label></p>
<p><label>テーマ:
     <input name="theme" type="radio" value="default">既定</option>
     <input name="theme" type="radio" value="light">明るい</option>
     <input name="theme" type="radio" value="dark">暗い</option>
   </label></p>

設定の型は既定値の型と一致するように自動的に変換されるため、上記のドロップダウンリストのような例で値が"0"のような文字列型になってしまうという事はありません。

なお、Managed Storageで管理者が設定を指定している場合、対応する設定項目は読み取り専用の状態になります。

about:configに相当する機能を付ける

XULアドオンでは、いわゆる隠し設定を定義しておき、about:configで値を変更して細かい挙動を制御するという事ができました。しかしWebExtensionsベースのアドオンでは統一的な設定の保存先が無く、またstorage APIで保存された情報には基本的にはアドオンの中からしかアクセスできません。about:debuggingからデバッガを起動すればアドオンの名前空間で任意のスクリプトを実行して設定を変えられますが、設定変更のためだけにデバッガを起動するのは億劫なものです。

このような問題への回避策として、Options.jsは初期化時に渡されたconfigsに定義されている全ての設定を一覧し変更できるようにする機能が含まれています。UIは以下のような要領です。

自動生成された設定一覧

全設定の一覧は、Optionsのインスタンスが持つbuildUIForAllConfigs()メソッドを実行するとページの末尾に挿入されます。

options.buildUIForAllConfigs();

また、メソッドの引数に任意のコンテナ要素を渡すと、その最後の子として全設定の一覧が挿入されます。

options.buildUIForAllConfigs(document.querySelector('#debug-configs'));

全設定の一覧の表示・非表示を切り替えるという機能は特にありません。ユーザーには見えないようにしておきたい場合、configs.debugのような設定を定義しておき、その変更を監視して以下の要領でコンテナ要素の表示・非表示を切り替えるといった形を取ると良いでしょう。

configs.$addObserver(aKey => {
  if (aKey == 'debug') {
    const container = document.querySelector('#debug-configs');
    container.style.display = configs[aKey] ? 'block' : 'none';
  }
});

まとめ

以上、Firefox用のWebExtensionsベースのアドオンにおける設定画面の提供を容易にするライブラリであるOptions.jsの使い方を解説しました。

XULアドオンでの感覚に近い開発を支援する軽量ライブラリは他にもいくつかあります。以下の解説も併せてご覧下さい。

つづき: 2018-07-10
タグ: Mozilla
2018-07-09

MozReviewでのFirefoxへのパッチ投稿方法

はじめに

前回の記事ではFirefoxへのフィードバックの仕方を紹介しました。この記事で紹介したように、Firefoxへのパッチ投稿は、以前はBugzillaにパッチファイルを手動で添付する形で行われていました。その方法は今でも通用するのですが、最近ではMozReviewというReview Boardベースのコードレビューシステムも導入されています。GitHubと同様にソースコードにインラインでコメントを付けられますし、パッチをリモートリポジトリでバージョン管理できるので、現在ではこちらを使用する方がおすすめです。しかし最初に少し設定が必要ですので、使い始めるまでに心理的障壁があるのも事実かと思います。そこで今回はMozReviewの使用方法について紹介します。

セットアップ

本記事で紹介するセットアップ方法はMozReview User Guideを元にしています。詳細はそちらを参照して下さい。

用意するもの
  • BMO(bugzilla.mozilla.org)のアカウント
    • まだBMOアカウントを取得していない場合は https://bugzilla.mozilla.org/createaccount.cgi で作成します。
    • Real nameにはYour Name [:ircnick]のような形で末尾にニックネームを付けておきましょう。
      • Review Board上ではこのニックネームがユーザー名として表示されます。
      • ただし、既に存在するニックネームは使用できません(代わりにメールアドレスのアカウント名+任意の数字になります)。
  • mozilla-centralのワーキングコピー
    • 前回の記事等を参考に、hg clone https://hg.mozilla.org/mozilla-centralで取得して下さい。
    • 本記事ではバージョン管理システムとしてMercurialを使用する場合のみを対象とします
      • MozReviewはgitでも使用できるようですが、本記事では対象としていません。
Review Boardへのログイン確認

MozillaのReview Boardは https://reviewboard.mozilla.org でアクセスできます。Bugzillaアカウントの登録が終わったら、Review Boardにもログインできることを確認してみましょう。アカウントはBugzillaと連携されているため、Review Boardのログインページにアクセスすると、自動的にログインすることができます。

Review Boardにログインできたら、画面右上のユーザー名を確認して、先ほど設定したニックネームを使用出来ていることを確認すると良いでしょう。

MozReviewユーザー名

APIキーの生成

自分がソースコードに対して行った変更をMercurialでreviewboard.mozilla.orgにpushするためには、BugzillaのAPIキーが必要になります。この後のMercurialのセットアップの際に必要になりますので、事前に生成しておきましょう。APIキーの作成はBugzillaアカウント設定画面のAPI Keysページで行うことができます。

APIキーリスト

Mercurialの追加セットアップ

MercurialでMozReviewと連携できるようにするためには、Mercurialに追加の設定が必要になります。この設定は前回の記事でも紹介した./mach bootstrapの中で対話的に行うことができます。ただし、前回はMozReviewについての説明は省略したため、それに関するセットアップは飛ばしてしまっているかもしれませんね。Mercurialを最初から設定し直したい場合は、既存の~/.hgrcを退避させて、./mach mercurial-setupで実行することができます。

$ cd /path/to/mozilla-central
$ mv ~/.hgrc ~/.hgrc.bak
$ ./mach mercurial-setup

後はコンソールに表示される指示に従って必要な項目(自分のフルネーム、メールアドレス、ニックネーム等)をセットアップして行けば良いです。Yes/Noで答えるものについては、基本的には全てYesで良いでしょう。

以下の項目については、一度MozReviewを設定してしまえは他の方法でパッチを投稿することは無いでしょうから、1で良いでしょう。

Commits to Mozilla projects are typically sent to MozReview. This is the
preferred code review tool at Mozilla.

Some still practice a legacy code review workflow that uploads patches
to Bugzilla.

1. MozReview only (preferred)
2. Both MozReview and Bugzilla
3. Bugzilla only

Which code review tools will you be submitting code to?  

以下の項目では、先ほどBugzillaで生成したAPIキーをコピペして入力します。

Bugzilla API Keys can only be obtained through the Bugzilla web interface.

Please perform the following steps:

  1) Open https://bugzilla.mozilla.org/userprefs.cgi?tab=apikey
  2) Generate a new API Key
  3) Copy the generated key and paste it here

./mach mercurial-setupが正常に終了したら、~/.hgrcを確認して必要な項目が適切に設定されていることを確認します。

[ui]
username = Your Name <your.name@example.com>

[extensions]
reviewboard = /path/to/home/.mozbuild/version-control-tools/hgext/reviewboard/client.py

[paths]
review = https://reviewboard-hg.mozilla.org/autoreview

[mozilla]
ircnick = nick

[bugzilla]
username = your.name@example.com
apikey = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

レビューリクエストの作成

前回の記事で紹介したように、Firefoxで何かパッチを投稿したい場合は、全てBugzilla上の該当Bugを起点に作業を行います。何か新機能を追加したり、不具合修正を行いたい場合は、まず該当するBugが既に存在するかどうかを確認し、無い場合は新たに自分で新しいBugをfileします。

該当Bugでソースコードに対して何か変更を行って、いざMozReviewでパッチを投稿したいとなった場合、まずはMercurialで手元のリポジトリに変更をコミットします。

$ hg commit

このとき、コミットメッセージの形式に注意しましょう。具体的には以下のような形式にする必要があります。

Bug [Bug番号] - [Bugの概要(一行)]

以下、Bugの詳細についての記述...

Mercurialでリモートリポジトリにpushする際、上記のコミットメッセージのBug番号から自動的にBugzillaの該当Bugにパッチが投稿されます。

また、末尾にr?ircnickという形式でレビュアーを指定すると、push後に自動的に該当レビュアーにレビューリクエストを投げることもできます。このレビュアーの指定はpushした後にReview BoardのWeb UIから行うこともできますので、必ずしもコミットメッセージに含める必要はありません。

以下に、筆者が実際にパッチを投稿した際のコミットメッセージを示します。

Bug 1306529 - OmxDataDecoder: Fix a stall issue on shutting down r?jya

Because the shutdown closure awaits finishing itself by
TaskQueue::AwaitShutdownAndIdle(), the function blocks infinitely.

The code is wrongly introduced at the following commit:

  * https://bugzilla.mozilla.org/show_bug.cgi?id=1319987
    * https://hg.mozilla.org/mozilla-central/rev/b2171e3e8b69

This patch calls it on mTaskQueue intead of mOmxTaskQueue to
avoid the issue.

詳細な議論はBug番号から辿ることができるため、コミットメッセージには必ずしも詳細な記述は必要ないようです。有った方が好ましいとは思いますが、慣れていない場合には、まずはBug番号と一行サマリを適切に記載することに注力すると良いでしょう。

ローカルリポジトリへのコミットが完了したら、リモートリポジトリにpushします。

$ hg push review

pushが完了すると、以下のようにReview Board上のURLが表示されますので、後の操作はWeb UI上で行うことができます。

...
submitting 1 changesets for review

changeset:  424886:1e4bfbffe0de
summary:    Bug 1451816 - [Wayland] Avoid closing popup window on button-press on Weston r?stransky
review:     https://reviewboard.mozilla.org/r/254690 (draft)

review id:  bz://1451816/takuro
review url: https://reviewboard.mozilla.org/r/254688 (draft)

この状態では、レビューリクエストはまだ公開されておらず、レビュアーにも通知は届いていません。
最後に以下のようにレビューリクエストを公開するかどうかを尋ねられますので、

publish these review requests now (Yn)?  

ここでyを入力すると、レビューリクエストが公開され、レビュアーに通知されます。
ここではいったんnとしておいて、Web UI上でレビュアーを指定した上でレビューリクエストを公開することも可能です。この場合は下記画面のReviewersでレビュアーを指定した後、Publishを押してレビューリクエストを公開します。

Review Board

これでレビューリクエストは完了ですが、Bugzillaの該当Bugの方も確認して、Review Board側のレビューリクエストと正しく紐付けられていることを確認しておくと良いでしょう。

なお、先ほどコミットした内容をhg exportで確認してみると、以下のようにMozReview-Commit-IDという行が追加されていることに気が付きます。

# HG changeset patch
# User Your Name <your.name@example.com>
# Date 1530688340 -32400
#      Wed Jul 04 16:12:20 2018 +0900
# Node ID 1e4bfbffe0de2870b7539a8ed42b9b6d6721fc87
# Parent  987ea0d6a000b95cf93928b25a74a7fb1dfe37b2
Bug 1451816 - [Wayland] Avoid closing popup window on button-press on Weston r?stransky

MozReview-Commit-ID: 2khX59CotQK

...

これは後にレビュー結果を受けてパッチを修正する際に必要になります。

パッチの修正

レビュアーによってパッチがレビューされ、Review Board上で修正箇所を指摘されたら、パッチを修正して再度Review Boardにpushすることになります。この際、同一のレビューリクエストに対する修正であることを指定するために、先ほどと同じMozReview-Commit-IDをコミットメッセージに含めてhg commitし、hg push reviewします。

Mercurialでのパッチ管理方法は本記事のスコープ外のため割愛しますが、パッチ(コミット)が1つのみで、ローカルリポジトリに過去のバージョンが不要である場合、もっとも簡単な修正方法はhg commit --amendで前回のコミットをやり直す方法でしょう。この方法の場合、コミットメッセージは特に修正しなければ前回のままとなりますので、MozReview-Commit-IDも前回と同じものが使用されます。ローカルリポジトリの修正は上書きされてしまいますが、リモートリポジトリ上では過去のバージョンも管理され、その差分を確認することもできます。

Review Board Diff

修正をpushしたら、Review Board上でレビュアーのコメントに返信をします。この際も、最後にPublishボタンを押すことを忘れないで下さい。なお、Review Board上の会話は自動的にBugzillaの該当Bugの方にも同じ内容が投稿されます。

レビューが通ったら

レビュアーによってパッチが問題ないと判断された場合、r?あるいはr-となっていた部分がr+に変更されます。この状態になったら、前回の記事と同様に、Bugzilla上で「Keywords」欄にcheckin-neededのキーワードを付加して、パッチをチェックインしてもらいます。

まとめ

MozReviewでのFirefoxへのパッチ投稿方法について紹介しました。

なお、記事中で例として紹介したBug 1451816はGNU/LinuxのWestonという環境でポップアップウィンドウのボタンを操作できないという問題です。本質的な修正は他のBugで行う必要があり、投稿しているパッチは必ずしも本体に入れるつもりの無いad-hocなパッチです。このようなパッチでも、アップストリームに報告しておけば同じ問題で困っている人の助けになるかもしれませんし、本質的な修正のヒントになる可能性もあります。自分のパッチに価値があるかないかは出してみないと分かりませんので、皆さんも臆することなくどんどんパッチを提出してみてはいかがでしょうか。

2018-07-05

Firefoxへのフィードバックの仕方:Windows編

近年はOSS・フリーソフトウェアのリポジトリを公開する場所としてGitHubが選ばれる事が多くなりましたので、フィードバックをする際も、GitHub上のIssuesで障害を報告したり、Pull Requestという形で具体的なソースコードの変更を提案したりといった形を取る事が多くなりました。同様のフィードバック方法が、BitBucketやGitLabなどの競合サービスにおいても可能です。

その一方で、歴史の長いプロジェクトではフィードバックの受け付けがメーリングリストに限られていたり、ソースコードがGitHub等での公開ではないため実装の提案をプルリクエストの形では行えなかったりという事があります。Firefoxもそういった例の1つで、不具合の報告や機能の追加要望はBugzillaで行い、実装内容は伝統的なパッチやMozReviewといった専用ツールで送付する必要があります。

そこでこの記事では、Windows上でFirefoxに対してパッチを含むフィードバックを行う際の流れをご紹介します。基本的にはHow to submit a patchという記事日本語訳)に書かれている内容ですが、本記事では周辺情報も併せてまとめています。Firefoxに直接的なフィードバックをしてみたいという方は、ぜひ参考にしてみて下さい。

まずは、ソースコードからFirefoxをビルドできるようにする

パッチを送付するようなフィードバックを行うためには、まずソースコードからFirefoxをビルドできる環境を整えておきます。1行だけの変更なのに大袈裟な……と思うかもしれませんが、Firefoxほどの規模のソフトウェアでは、些細な変更が予想もしない所に影響を及ぼす事があります。ファイルの内容に変更を行う際は必ずビルドと自動テストを行い、後退バグ(regression)が発生していない事を確認する必要があります。

基本的には、Windows上でのビルド環境一式がセットになっている「MozillaBuild」というツールを使う事になります。これにはMSYSベースのUnix系コマンド群やPython、Mercurialなどが含まれています。また、以下の手順の中に含まれていますが、それ以外の必要なツール類も半自動でインストールされるので、準備は非常に簡単です。

以下は、2018年7月3日時点でのビルド環境整備手順の要約となります。

  1. MozillaBuildの最新版リリースをダウンロードし、インストールする。一般的にはc:\mozilla-buildにインストールする。
  2. C:\mozilla-sourceの位置にフォルダを作る。Firefoxのソースコード一式はこの配下に置く事になる。
  3. c:\mozilla-build\start-shell.batを実行して、MozillaBuildのシェル(Bash)を起動する。
  4. export PATH=$PATH:~/.cargo/bin という行を、~/.bash_profile~/.bash_login、または~/.profileのいずれかに追記する。echo 'export PATH=$PATH:~/.cargo/bin' >> ~/.bash_profileのようにするとよい。
  5. MozillaBuildのシェルを一旦終了し、c:\mozilla-build\start-shell.batで起動し直す。
  6. cd /c/mozilla-sourceして、パッチを書く対象となるFirefoxのMercurialリポジトリをcloneする。
    • 通常は、最新の開発版のリポジトリであるmozilla-centralをcloneする。hg clone https://hg.mozilla.org/mozilla-centralでcloneできる。以下の説明はこちらのケースを前提とする。
    • ベータフェーズにあるバージョンへのパッチを作りたい場合は、releases/mozilla-betaをcloneする。hg clone https://hg.mozilla.org/releases/mozilla-betaでcloneできる。
    • 既にリリースされたバージョンへのパッチを作りたい場合は、releases/mozilla-releaseをcloneする。hg clone https://hg.mozilla.org/releases/mozilla-releaseでcloneできる。
  7. cd mozilla-centralしてリポジトリに入り、./mach bootstrapを実行する。これにより、Visual StudioやRustなどのビルドに必要なソフトウェア群が自動インストールされる。

必要なソフトウェア群が揃ったら、./mach buildでFirefoxをビルドして、ビルドが完了後に./mach runでFirefoxを起動してみましょう。無事に起動すれば、準備は完了です。

ビルド時間を短縮するには

Firefoxのビルドに要する時間のほとんどは、C++で実装された基盤部分のバイナリのビルド時間です。フィードバックしたい変更がGUI部分のJavaScriptの実装に関わる部分だけであるという場合、ビルド済みバイナリを使ってビルド時間を短縮する事もできます。これはartifact buildと呼ばれます。

artifact buildを使うためには、リポジトリ直下に置く.mozconfigというファイルを使ってFirefoxのビルドオプションを指定する必要があります。具体的には以下の要領です。

cd /c/mozilla-build/mozilla-central
echo 'ac_add_options --enable-artifact-builds' >> ./.mozconfig
echo 'mk_add_options MOZ_OBJDIR=./objdir-frontend' >> ./.mozconfig

この状態で./mach buildを実行すると、C++のコンポーネントをビルドする代わりにビルド済みバイナリが自動でダウンロードされてきて、それと組み合わせる形でFirefoxがビルドされるようになります。

筆者はJavaScript部分だけのフィードバックとC++部分に関わるフィードバックのどちらも行う場合があるため、/c/mozilla-source/mozilla-central-artifact/c/mozilla-source/mozilla-central-fullという具合にリポジトリ自体を2つcloneしておき、片方をartifact build専用にして使っています。

「自分の名前」を設定しておく

作業を始める前に、Mercurialリポジトリへのコミットに表示される作業者名を設定しましょう。これは、MozillaBuildのシェルから見た時に~/.hgrcの位置にあるファイルで指定します。具体的には、以下のように書きます。

[ui]
username = YUKI "Piro" Hiroshi <yuki@clear-code.com>

パッチを書けそうなBugを報告する、または見付ける

Firefoxでは、不具合の報告や新機能の提案はすべて「Bug」としてbugzilla.mozilla.orgのバグトラッカーで管理されています。

Bugを登録する場合は、まずアカウントを作成してログインします。ログイン済みの状態だと、ページ上部に鉛筆型のアイコンの「File a new bug」というリンクが現れ、そこから新しいBugを報告・登録することができます。Bugの登録時にはまずそれがどの部分(プロダクト)の話なのかを指定する必要がありますが、基本的には、FirefoxというWebブラウザ固有のUIや機能に関わる話題は「Firefox」プロダクト、Thunderbird等と共通の基盤部分のうちJavaScriptで実装されている物は「toolkit」プロダクト、C++で実装された低レベルの基盤技術に関わる物は「core」と覚えておくとよいでしょう。また、プロダクトの選択後はさらに細分化された「コンポーネント」を選択する必要がありますが、リストの中からそれらしいものを選んでおけば大丈夫です。もしプロダクトやコンポーネントを間違えたとしても、他の開発者の人がBugの内容に合わせた適切なプロダクト・コンポーネントを再設定して誘導してくれます。その後、「summary(要約)」と、コメントとして「steps to reproduce(詳細な再現手順)」「expected result(期待される結果)」「actual result(実際の結果)」を入力して投稿すれば、Bugの報告は完了です。

既ににあるBugの中からパッチを書けそうな物を探す場合は、good-first-bugというキーワードで検索すると、比較的難易度が低いと考えられているBugを一覧表示することができます。興味のある話題のBugを見付けたら、これまでの経緯を読んで、どのようなパッチが求められているのかを読み取ってみましょう。

パッチを書くBugを登録あるいは検索結果から見付けたら、他の人が同時に作業を始めないように、自分がこれからパッチを書いてみますという事を宣言しておくとよいでしょう。これは、単純に「Now I'm trying to write a patch for this bug.」のように書いてもいいですし、「Assignee(担当者)」という欄に自分のアカウント名を設定しても良いです(前者のようにだけしていても、他の人が気を利かせてあなたの名前をAssigneeに設定してくれる事もあります)。

パッチを実際に作成する

準備ができましたので、早速パッチを作ってみる事にします。

クリーンな環境に戻す

以前にパッチを作るために変更した結果が残っていると、作成したパッチが期待と異なる内容になってしまいます。Mozilla公式のリポジトリの内容に対するパッチを作るために、まず以下のようにコマンドを実行し、リポジトリの状態をMozilla公式の物と同じに揃えておきましょう。

hg update -C default # 変更を全て取り消す
hg pull -u # Mozillaのリポジトリから変更点を複製し、手元のコピーに反映する
作業用のブランチを作る

次に、作業用の一時的なブランチを作成します。hg branch fix-bug-xxxxx-workingのように実行して、新しいブランチの作成を予約しましょう(gitではgit checkout -b branch-nameのようにするとその瞬間にブランチが作られますが、Mercurialではこのように「ブランチの作成を予約」した後で何かコミットした段階で初めてブランチが作られます)。

このブランチそのものは、後でパッチを作るための素材として使うだけなので、名前には「working」を付けて作業用である事を示しています。(用事が終わったら、このブランチはhg branch -C xxxxxコマンドで消去してしまって問題ありません。)

コードを改変する

問題を修正するための変更(新機能を追加するための変更)を行います。変更はhg commit path/to/fileでコミットすることができ、新しいファイルを追加する場合はhg add path/to/fileでリポジトリに登録できます。作業経過を失わないように、こまめに「Bug XXXXX - 変更の概要」といった要領のコミットメッセージを付けてコミットするようにしましょう。コミット時にはEmacsが起動しますが、普通にコミットメッセージを入力して、上書き保存して終了すればOKです。

方針を間違えたなどの理由でコミットを取り消したくなった場合は、hg log -Gでコミットツリーを表示して、取り消したいコミットの番号(XXXX:YYYYYYYという形式の、数字と文字列の組)を調べた上で、調べたコミットの番号の「:」より手前の数字を指定してhg strip XXXXと言うコマンドを実行します。こうすると、指定したコミットとその後に続くコミットが無かったことになります。

また、コミット単位ではなく作業そのものを最初からやり直したいという場合には、ブランチそのものを破棄して作り直すのが手っ取り早いです。その場合はhg update -C defaultでデフォルトブランチに戻した上で、hg branch -f fix-bug-xxxxx-working-fオプションを付けて同名のブランチ作成を予約すれば、ブランチ作成の時点から作業をやり直す事ができます。

自動テストを書く

不具合の修正でも新機能の追加でも、対応する自動テストの追加は原則として必要です。変更対象のファイルの近傍に自動テストのファイル群が配置されている事が多いはずなので、それらを参考にして、関連していそうなテストファイルにテストケースを追加したり、あるいはテストのファイルそのものを新たに追加したりしましょう。

新しいテストファイルを追加する場合は、hg addでのリポジトリへの登録だけでは不十分で、同じディレクトリのiniファイルにもテストファイル名を追記する必要があります。

自動テストを実行する

自分で追加した自動テスト(テストケースを追加したファイルや、自分で追加したテストファイル)は、./mach test path/to/test/dir/or/fileとすると適切なフレームワークで実行する事ができます。テストが確実に通る事を確認しておきましょう。また、ディレクトリを指定すると複数のテストを一括実行できますので、関連する他の機能に意図しない影響を及ぼしていないか(他のテストが失敗するようになっていないか)も確かめておきましょう。

(なお、何度もパッチを投稿していると、tryserverという自動テスト専用のサーバーの使用権を貰えることがあります。tryserverを使うとWindows以外のプラットフォームでも自動テストを実行できますし、手元での実行ではないのでテストの実行中も他の作業を並行して行えるので、非常に有用です。tryserverの使用権を持っている場合は、./mach try -b o -p linux,macosx64,win64 -u all -t none --no-artifactというコマンド列を実行するだけで、現在作業中のブランチの内容を反映した状態でtryserver上で全てのテストを実行する事ができます。)

変更の完了とパッチ作成の準備

作業用ブランチ上で複数回コミットしていた場合、変更内容を1つにまとめたコミットを作成してパッチにします。これは以下の手順で行うことができます。

hg diff -r default > ../working.diff # 変更内容を差分ファイルに出力する
hg update -C default # デフォルトブランチに切り替える
hg pull -u # 念のためデフォルトブランチの状態を最新にする
hg branch -f fix-bug-xxxxx # ブランチを作り直す
patch -p 1 < ../working.diff # 差分ファイルに出力した内容を書き戻す
hg add path/to/added/files # ファイルを追加していた場合は、この時点で手動で追加し直す。
hg commit

この時のコミットメッセージは「Bug XXXXX - 変更の概要」のようにします。このコミットメッセージはパッチに含まれる事になります。

複数回のコミットがあった状態のままでパッチを作成すると、1コミットが1パッチとして分割される事になります。大規模な変更を行う場合はパッチも複数に分けて段階的に反映する場合がありますが、そのような大規模な変更を行う機会は一般の外部コントリビューターはまず行う機会がありませんので、基本的には上記の手順で1コミットにまとめるようにしましょう。

なお、自分が作業を始めた時点のデフォルトブランチの状態とhg pull -uで更新したデフォルトブランチの状態が異なっていた場合(自分が作業中に他の人の変更が反映された場合)、パッチの適用に失敗する事があります。このような場合、衝突箇所を修正して(自動テストも再実行して)、「最新のデフォルトブランチに対して行った変更」という体裁のパッチに手直しする必要があります。

変更の完了とパッチの提出

準備が整ったら、このブランチの変更内容をパッチとしてBugに添付します。hg bzexport -eというコマンドを実行すると、Emacsが起動してコメントの入力を求められます。対応するBugの番号が最初の行に「Bug XXXXX」と表示されていれば大丈夫なので、そのまま保存して終了して下さい(「No changes made; continue with current values (y/n)?」と訊かれるので、yと入力して下さい)。コミットメッセージにBugの番号を入れ忘れていた場合は、対応するBugの番号を「Bug XXXXX」のように記入した上で、変更を保存してEmacsを終了します。すると、パッチがBugに自動的に添付されます。 (なお、同じBugに複数回パッチを送信すると、前のパッチは自動的に「Obsolete」扱いになります。)

Mercurialの拡張機能がまだインストールされていないと、hg bzexport -eはエラーになります。その場合は、先に./mach mercurial-setupを実行して必要なプラグインをMercurialにインストールしておくか、またはhg export > ./bugXXXXX.patchで手動でパッチを作成して手動で当該Bugにファイルを添付します。この場合、古いパッチを適宜手動で「obsolete」にする必要があります。

Mozillaでは、全てのパッチはレビュアーによるレビューを経てからマージされる運用になっています。このパッチをFirefoxに取り込んでもらうためには、レビューを依頼しなくてはいけません。

パッチがBugに添付されたら、Bugのページをブラウザで開いて、添付されたパッチの「Details」のリンクをクリックします(hg bzexportを使わない場合は、パッチファイルの添付時のフォームでそのまま操作します)。「review」という欄があるので「?」を選択し、その隣の入力欄にレビュアーを指定してレビューを依頼します。基本的にはそのモジュールの担当者を設定するのですが、誰に依頼したらいいか分からない場合は、suggested reviewersという所をクリックすると、お薦めレビュアーが出てきます(モジュールから自動的に検索された結果が一覧表示されます)。基本的には、レビューのキューの数が少ない人を設定するのがお薦めです。ただし、レビューの件数が多くても猛烈なスピードで消化する人や、レビューの件数が少なくてもなかなかキューが減らない人もいるので、できれば類似のBugを見て活動がアクティブな人を設定する方が望ましいです。あるいは、変更対象のファイルの変更履歴を確認し、最近投入されたパッチのレビューを担当した人を設定するのも有効です。

その後、指定した人物によりレビューが行われます。レビューを無事通過できれば、パッチのメタ情報に「r+」と表示されるようになります。

パッチの内容に不具合や考慮不足、コーディングルールに反している部分などがあると、レビューが却下され、パッチのメタ情報のステータスが「r-」になります。その場合は、指摘された問題点を修正してパッチを再提出しましょう。

なお、このように伝統的な差分ファイル形式のパッチを使ったパッチ提出のやり方の他に、現在ではMozReviewという専用の仕組みを使ったレビュー運用も行われています。こちらについては別途、別の記事で詳しく解説します。

チェックインの依頼

パッチのステータスが「r+」になったら、いよいよチェックインです。Bugの「whiteboard」欄にcheckin-neededと記入してBugを更新しておくと、担当者の人がそれを見付けて、だいたいその日の中に「inbound」というリポジトリにパッチをチェックインしてくれます。このinboundの自動ビルドと自動テストで何もエラーが検出されなければ、同じ内容が自動的にmozilla-centralなどのリポジトリにチェックインされます。

もしinboundのビルドや自動テストが失敗した場合は、その旨のコメントがBugに書かれますので、適切に対応してパッチを再提出する事になります。もし自分の行った変更が原因で他の自動テストが失敗したようなら、それらのテストに悪影響を与えた原因を修正したり、あるいは、もし他の部分の方に問題がある場合(例えば、やっつけで実装されていたテストが、自分の行った変更の影響で失敗するようになった場合など)はそちら側を修正したりします。

また、inboundへのチェックイン自体に失敗したという事でパッチが差し戻される事もあります。これは、自分が作業した時のデフォルトブランチの状態と、担当者がinboundにパッチをチェックインしようとした時の最新の状態とがずれていて、パッチが衝突してしまったという場合に起こります。その場合、複数コミットを1つのコミットにまとめる時と同じ要領で、最新のデフォルトブランチを対象にしてパッチを作り直しましょう。他の変更と衝突はしていたものの、パッチは全体的に以前の物から代わっていないと言える場合は、再レビュー依頼を省略できます。再提出したパッチに対して「r?」を設定する代わりに「r+」を自分で付けて、コメントには「r+ is carried-over from the previous patch」のように書いておきましょう。

Beta版のBugを直したい場合は

次期リリースのBeta版を使っていて見付けた問題でも、まずはmozilla-centralに対して修正を行います。以下は、変更が既にmozilla-centralに反映されたという前提での話になります。

まず、releases/mozilla-betaのリポジトリをローカルにcloneして、mozilla-centralにチェックインされたパッチをpatch -p 1 < path/to/patch.diffで反映します。もし衝突が発生してパッチを反映できなかった場合は、衝突箇所を修正してコミットし、新しいパッチを作り直して改めて提出します。内容的に変更がなければ、ここでもレビューを省略して「r+」を自分で付け、前のパッチからレビュー済みの状態を引き継いだ旨を書いておきます。

元のパッチがそのままBetaに反映できる状態、またはBeta向けにパッチを再提出し終えた状態で、そのパッチをreleases/mozilla-betaに取り込んでもらうよう依頼する事を、uplift申請と言います。Beta版またはリリース版に対しては原則としてこのuplift申請を経由してパッチが取り込まれるようになっています。

uplift申請をするには、当該パッチの詳細情報の画面で「approval-mozilla-beta」欄に「?」を設定します。すると、コメント入力欄にuplift申請のテンプレートが自動入力されますので、「ベータフェーズでクオリティを高める段階にある今、新たな問題を引き起こすリスクを押してでもこの変更を反映するべき理由」「この変更を反映しても問題は起こらないと言える理由」を説明するよう各欄を埋めて投稿します。実際の申請例(Firefox 60betaに対して、セーフモード無効化のポリシー設定を導入するパッチのuplift申請を行っている物)も参照して下さい。申請が受理されれば、パッチはチェックインされます。

充分な理由を示せていなかった場合、申請は却下される場合もあります。その場合、改めてその修正の重要性を説明し直したり、味方に付いてくれる人(Mozillaの中の人)にコメントを求めたりという形で、説得・交渉を行う事になります。

まとめ

以上、Windows上での作業を前提とした、Firefoxへのパッチ提出の流れを解説しました。

「Firefoxのような大規模プロジェクト、しかもGitHubではない独自のやり方でソースコードや問題を管理している所にコントリビュートする」というのは、GitHub上でフィードバックした事がある人でも、心理的に高いハードルがあるかもしれません。しかし、根本的な部分ではそう大きな違いはなく、むしろ、Bugzillaのような高機能のバグトラッキングシステムやinboundのような規定は、GitHub上でのプロジェクト運営では「運用でカバー」されているものをシステムとしてきちんと体系化した物と言う事ができるでしょう。皆さんも必要以上に恐れずフィードバックしてみて下さい。

2018-07-03

2008|05|06|07|08|09|10|11|12|
2009|01|02|03|04|05|06|07|08|09|10|11|12|
2010|01|02|03|04|05|06|07|08|09|10|11|12|
2011|01|02|03|04|05|06|07|08|09|10|11|12|
2012|01|02|03|04|05|06|07|08|09|10|11|12|
2013|01|02|03|04|05|06|07|08|09|10|11|12|
2014|01|02|03|04|05|06|07|08|09|10|11|12|
2015|01|02|03|04|05|06|07|08|09|10|11|12|
2016|01|02|03|04|05|06|07|08|09|10|11|12|
2017|01|02|03|04|05|06|07|08|09|10|11|12|
2018|01|02|03|04|05|06|07|08|09|10|11|
タグ: