Edited at

Implementing keepalive on Fetch API

More than 1 year has passed since last update.

はじめまして、ひらのです。Blink 上でネットワーク関連の API (XHR, fetch API, WebSocket, …) の実装や、ローディング関連の実装を行っています。

本稿では、Fetch API における Request および RequestInit の keepalive プロパティの実装について解説します。このプロパティの実装は、Chromium のリソースローディングパスのかなりトリッキーな既存実装をいじる必要があり、また、Chromium のマルチプロセスアーキテクチャを意識することも必要なため、(いわゆる下位レイヤーですでに実装されているスイッチに配線するだけの実装と違い) 楽しい実装です。


リンク集

本稿では仕様の説明はほとんど行いません。以下を参照ください。


Chromium の リソースローディングパイプライン

本節では、Chromium のリソースローディングを概観します。まず、Fetch spec における概念的なレイヤリングを見て、Chromium の実装が仕様とどのような部分で異なっているかを解説します。

Fetch spec は、大きく二つの部分からなります。一つは Fetch algorithm, 二つ目は Fetch API です。前者はあらゆる Web Spec で使われるリソースローディングを定義したもので、後者は “fetch” 関数を定義したものです。Fetch algorithm は、以下のような階層構造をなしています。例えば、Service Worker によるリクエストの捕捉は HTTP fetch の中で行われます。

対して、下が Chromium の実装 (2017年二月あたり) です。かなり違っていますね?

まず、CORS の実装が二つあります。CORS1 と、ResourceFetcher に含まれているものです。前者は CORS preflight を処理できますが、後者はできません。preflight 処理を必要とする fetch API や XHR は、前者で CORS 処理を行い、必要としない script や image などは、後者で CORS 処理を行います。前者と後者が同時に適用されることはありません。

SendBeacon は、ResourceFetcher も通りません。代わりに、ResourceFetcher 内で実装されている一部の 処理 (CSP や CORS など) を独自に実装しています。

Chromium は、マルチプロセスアーキテクチャを取っています。いわゆる DOM が実装されていたり、JavaScript が動作するのは、レンダラプロセスと呼ばれるプロセスの中です。このプロセスはタブが閉じたりすることにより比較的簡単に殺されます。本稿で扱うリソースローディングは、レンダラプロセスでリクエストが作られますが、ResourceLoader より先の処理はブラウザプロセスと呼ばれるネットワーク処理が行えるプロセスに移譲されます。これは Fetch spec にない概念です。

Service Worker によるリクエストの捕捉も、spec と大きく違います。Spec においてはかなり浅い位置 (HTTP fetch の、CORS より前) に分岐があったのですが、Chromium の実装は net::URLRequest の中で行われます。これにより、CORS や request header の修正など、本来 Service Worker に渡るリクエスト / 返るレスポンスに対して行われるはずのない処理が行われます。これについては、色々な場所で辻褄合わせが行われています。例えば https://crbug.com/595993 は辻褄合わせが失敗している例です。


SendBeacon と keepalive

本稿は Fetch API の keepalive の実装について解説するものでした。”keepalive” とは何でしょう?

通常のリソースローディングは、リクエスト元の環境がデタッチされた時に停止されます。


When a fetch group is terminated, for each associated fetch record whose request’s done flag or keepalive flag is unset, terminate the fetch record’s fetch.

https://fetch.spec.whatwg.org/#fetch-groups


例外が keepalive flag です。この flag がセットされたリクエストは、 このときも停止されません。簡単に言うと、タブが閉じられた後もリソースローディングが継続されるということです。


A request has an associated keepalive flag. Unless stated otherwise it is unset._

Note: This can be used to allow the request to outlive the environment settings object, e.g., navigator.sendBeacon and the HTML img element set this flag. Requests with this flag set are subject to additional processing requirements._

https://fetch.spec.whatwg.org/#request-keepalive-flag


この機能は、広告やトラッキング目的で使われます。たとえば、ページがアンロードされる直前にサーバにそのことを知らせようとすると、先程解説したとおりアンロードした時にリクエストがキャンセルされてしまいます。ページのアンロードをブロックしたければ Synchronous XHR を使うしかありませんが、これはページ遷移をブロックするため、特にリクエストに時間がかかった場合、ユーザーエクスペリエンスの観点から望ましくありません。keepalive flag をセットすると、ページ遷移をブロックすることなく、サーバにリクエストを送ることができます。ディベロッパーは、RequestInit.keepalive をセットすることにより keepalive flag をセットすることができます。

SendBeacon という機能を聞いたことがあれば、この説明がそのまま当てはまることがわかると思います。RequestInit.keepalive は fetch API の多くの機能と共に使えるため、SendBeacon より柔軟で、多機能であることが特徴です。実際、仕様においては SendBeacon は keepalive flag を使って定義されています。


Chromium のプロセスモデルと keepalive

上述の keepalive flag と、Chromium のプロセスモデルの相性が悪いことにお気づきでしょうか。ユーザーがタブを閉じたとき、それに関連付けられたレンダラプロセスは、(ほかから必要とされていない限り) 終了します。通常の fetch においては、fetch group の停止とともにリクエストが停止されるため問題ありませんが、 keepalive flag がセットされた場合、UA はこのリクエストを継続処理しなければなりません。レンダラプロセスが終了した場合、再掲図の process wall より上の部分は完全に処理を停止してしまいます。

一見したところ、レンダラプロセスが停止する場合には JavaScript program は fetch の出力を受け取れないため、process wall より上の部分は必要なく、処理をブラウザプロセスで継続すれば良いように見えます。これは間違いで、以下のような場合に問題になります。


  • Redirect

  • CORS preflight

セキュリティチェック機構である Content Security Policy (CSP), Mixed Content check (MIX), CORS はリダイレクトのたびにチェックされます。これらのチェックはレンダラプロセス内で行われるため、リダイレクトレスポンスが返ってきた時にブラウザプロセスはリダイレクト可能かどうかをレンダラプロセスに問い合わせなければいけません。レンダラプロセスがいない場合は、この判断が行えなくなります。

CORS preflight もほぼ同じです。CORS preflight を送信した後でレンダラプロセスがいなくなってしまったら、preflight のレスポンスが返ってきても実際のリクエストを送信するべきかどうかが判断できません。


DetachableResourceHandler

ところで、SendBeacon は keepalive のサブセットでした。そして、SendBeacon はすでに実装されています。上のような困難があるとすれば、SendBeacon はどのように実装されているのでしょう?

また同じ図を再掲しますが、図中に DetachableResourceHandler というものがあります。

このコンポーネントはブラウザプロセス中に実装されていて、図中の説明の通り、レンダラプロセスが死んでもリクエストを継続処理するためのものです。ただし上で説明したとおり、レンダラプロセスが死んでしまった場合にはリクエストを処理するための情報が欠けてしまっている状態であり、完璧に処理を継続することはできません。SendBeacon は幾つかの制限 (仕様上と実装上両方) を課すことによりレンダラプロセスがいなくなってしまった後の処理を実行しています。

まず、CORS preflight について。Chromium は、CORS preflight を必要とするような SendBeacon リクエストをサポートしていません。

preflight 以外の CORS については、仕様上必要とされません。仕様を読むとわかりますが、Content-Type の値が CORS-safelisted request-header である場合には (つまり、preflight が必要とされない場合には)、mode が “no-cors” にセットされます。これは、preflight が必要ない場合には CORS 自体を無効にして良い、ということです。これにより、リダイレクト時の CORS については心配する必要がありません。

リダイレクト時の CSP および MIX については、無視されます。つまり、CSP の connect-src を特定のオリジンに制限しても、SendBeacon のリダイレクト処理においてはチェックが無視される事があります。ここについては、Chromium は仕様にそっていません。


keepalive と DetachableResourceHandler

SendBeacon が DetachableResourceHandler で実装されているところまで見てきました。これはかなりの綱渡りで、RequestInit.keepalive の実装に使えるものではありません。なぜなら、RequestInit.keepalive は SendBeacon より柔軟で、色々なオプションもつけられますし、仕様上の特別な制限も少ないからです。Fetch API の複雑さを考えると、アドホックに keepalive flag を実装すると色々なコーナーケースでセキュリティに悪影響を及ぼすことが予想されました。我々は大きく二つの解決案を考えました。詳しくはデザインドキュメントにまとめられていますが、


  1. CORS / MIX / CSP のセキュリティ機構をブラウザプロセスに移動させる

  2. keepalive request が生きている限りはレンダラプロセスを延命させる

です。前者は、レンダラプロセスがいなくなっても keepalive request を処理できるようにする、後者はそもそもレンダラプロセスがいなくならないようにする、という方向性です。

比較すると、前者のほうが望ましい挙動を実現できます。なぜなら、レンダラプロセスはメモリ不足などの理由により、強制的に終了させられることがあり、後者をとるとそのような場合にリクエストの処理を継続できないからです。また、リソース消費量も前者のほうが少ないことが予想されます。一方、実装コストは後者のほうが低く、特に CSP と MIX をブラウザプロセスに移動させることは困難が予想されました。それに対して、後者の実装は後に解説するように既存の仕組みを使って実現できます。

結局、メモリ使用量が許容できるのであれば後者で実装して、将来的に CSP / MIX / CORS をブラウザプロセスに移動できることがあれば前者の方式に切り替えよう、ということになりました。


実装

ということで、実装しましょう。この段階ではメモリ使用量が許容可能かどうかわかっていないため、すでにユーザーが存在する SendBeacon で実験しよう、ということになりました。https://crbug.com/695939 が対応するチケットです。


  1. SendBeacon の実装を現在のアドホック実装から通常の fetching process に統合し、keepalive flag を実装しやすいようにする。

  2. keepalive flag を実装し、レンダラプロセスの寿命を延命できる仕組みと条件付きで結びつける。このスイッチで A/B テストを行って、メモリ使用量が許容可能か評価する。

  3. RequestInit.keepalive を実装する


Remove PingLoaderImpl

第一段階は、リファクタリングです。上で解説したように、SendBeacon のために色々と特殊なことをしていたクラス (PingLoaderImpl) の機能を、ResourceFetcher にマージしていく作業です。

結果として、Chromium の実装は下の図のよう変更されました。


KeepAliveRendererForKeepaliveRequests

第二段階は、keepalive flag の実装です。

上でも述べましたが、レンダラプロセスを延命させる仕組みはすでに実装されていました。Shared Worker および Service Worker のための仕組みで、ここで少し触れられていますが、これらのワーカーをホストしている場合レンダラプロセスは含まれるフレームがすべていなくなっていても延命されます。これを利用すればあまり苦労せずに実装できるはずです。

keepalive flag がセットされたリクエストは、リクエスト時にブラウザプロセス上にあるフレーム情報を管理するオブジェクト (RenderFrameHost) に、レンダラを延命させるためのハンドル (KeepAliveHandle) を要求します。このハンドルがすべてなくなるまで、レンダラプロセスは延命されます。

// An opaque handle that keeps alive the associated render process even after

// the frame is detached. Used by resource requests with "keepalive" specified.
interface KeepAliveHandle {};


interface FrameHost {

// Creates and returns a KeepAliveHandle.
IssueKeepAliveHandle(KeepAliveHandle& keep_alive_handle);

}

https://cs.chromium.org/chromium/src/content/common/frame.mojom

さらに、KeepAliveRendererForKeepaliveRequests というスイッチを用意し、

有効になっている場合は、KeepAliveHandle で SendBeacon (の keepalive 部分) を実装

無効になっている場合は、従来通り DetachableResourceHandler で SendBeacon (の keepalive 部分) を実装

という状態にして A/B テストを行いました。メモリ使用量の有意な増加は観察されませんでした。


RequestInit.keepalive and Request.keepalive

実験の結果が良かったので、RequestInit.keepalive と Request.keepalive をこの方式で実装することにしました。すでに内部的に keepalive flag が実装されているので、この実装は簡単です。


現状

RequestInit.keepalive と Request.keepalive は実装しましたが、まだ実験用の段階で、デフォルトでは有効にされません。これは主に Quota management がまだ実装されていないからです。また、CORS preflight を必要とするリクエストについても実装の都合で現在無効にされています。


Future Work

上で、keepalive の実装には二方式あるが、実装が簡単な方を取った、という話をしました。セキュリティ機構をブラウザプロセスに移動させることは、実装コストが高いため、将来的に移動された場合には keepalive の実装を切り替える、ということも書きました。CSP / MIX / CORS のうち、CORS についてはすでにチケットがあります。CSP / MIX については、ちゃんとした計画はないのですが、誰かがやってくれるといいなあ、と思っています。


Acknowledgments

Thanks to

for reviewing.