Apple

JavaScriptCoreをプラグインごと子プロセスに切り出す

  • JavaScriptCore
  • macOS
  • Swift
  • App Sandbox

JavaScriptCoreの JSValue が持つ call(withArguments:) は同期呼び出しなので、プラグインが while (true) {} に入ると呼び出しスレッドは戻ってきません。試験的に開発しているターミナルアプリのプラグインシステムで、JavaScriptCoreごと子プロセスに切り出し、コールドスタートからkill後の復旧までの7項目を実測した記録です。

while(true) で呼び出しスレッドが戻らなくなる

自社で試験的に開発しているターミナルアプリに、プラグインシステムを載せる準備を進めています。 実行エンジンにはJavaScriptCoreを使い、プラグイン側は globalThis.transform = (raw, ctx) => result という決まった形の関数を1つ持つだけ、という枠組みは前段のPoCで動かしていました。

前段のPoC(swift-jscore-plugin-sandbox)は、プラグイン1つにつき JSVirtualMachineJSContext を1組持たせ、host.fetch のようなホスト側APIを許可された権限の分だけブリッジに足していく作りです。 権限を渡していないAPIはそもそも JSContext に存在しないので、JS側からは見えないように制御することができています。

問題は JSValue.call(withArguments:) がJavaScriptCore(以下JSC)への同期呼び出しだということです。

プラグインが

globalThis.transform = (raw, ctx) => { while (true) {} };

を実行すると、call を呼んだホスト側のスレッドはJSCの内部に入ったまま戻ってきません。Swiftの actor もメッセージパッシングも、呼び出しの内側までは届きません。

今の方式を続ける場合の選択肢は2つで、JSContextGroupSetExecutionTimeLimit というPrivate APIを呼ぶか、詰まったスレッドをアプリの再起動まで1コア分占有し続けるかでした。

プラグインがクラッシュした場合はさらに厳しく、ユーザーが実行中のシェルまで巻き込まれます。

そこで、プラグインをホストプロセスの外、子プロセスに切り出せるかを検証しました。

posix_spawn と socketpair でプラグインを1プロセスに切り出す

構成は、プラグイン1つにつき posix_spawnPluginHelper を1プロセス起動し、親子間を socketpair(AF_UNIX, SOCK_STREAM) でつないで、length-prefixed JSONでリクエストと応答をやり取りする形にしました。

GitHubsandbox/swift-jscore-oop-poc at main · corrupt952/sandbox遊び場. Contribute to corrupt952/sandbox development by creating an account on GitHub.

XPC serviceも検討はしていましたが、署名時にバンドルへ焼き込まれるので、実行時にインストールするプラグインと噛み合いません。launchdが自動でプロセスを再起動する挙動も、「ホストがkillした」という状態を曖昧にしてしまいます。

トランスポートにパイプではなくsocketpairを選んだのは、ファイルディスクリプタ(fd)を子へ渡すSCM_RIGHTSAF_UNIX ソケットでしか通らないためです(fd渡しについては後述します)。 フレーミング自体のコストはパイプでもsocketpairでも変わりませんが、後からトランスポートを差し替えるとなると書き直しになります。

capabilityのポリシーゲートはホスト側に残しました。ヘルパー内の host.fetch は許可リストをその場でチェックせず、プラグインからホストへ逆方向RPCで問い合わせ、ホストに判断させます。

let fetch: @convention(block) (String) -> Any? = { urlString in
  callHost(method: "fetch", arg: urlString)
}

いつSIGKILLされるか分からないプロセスに、自分自身の許可判定を持たせるのは筋が悪いという判断です。ハングしたヘルパーが抱える許可リストのコピーは、そのハングを引き起こしたプラグイン自身にとって書き換え放題になってしまいます。

このアーキテクチャの効きどころは、詰まるのがヘルパー1プロセスだけで、ホストは生きたまま観測と対処を続けられる点にあります。

通常時はホストとヘルパーがsocketpair越しにtickを送受信している。ヘルパーがwhile(true)に入るとホストはデッドラインで応答なしを検知し、ヘルパーをSIGKILLする。新しいヘルパーが起動し、8.3ms後には通常のtick応答に戻る

while(true) を実行するスクリプトを読み込み、デッドライン付きで tick を呼ぶと、デッドラインちょうどでハングを検知できました。そこから対象のpidへSIGKILLを送ってreapすると、proc_pid_rusageproc_pidinfo もそのpidに対して失敗するようになり、kill(pid, 0) も届かなくなります。

スレッドもfootprintもcorpseも残りません。

新しいヘルパーを起動して同じ tick を送ると、8.3ms 後に応答が返ってきました。

7項目を実測する

while(true) の検知だけでなく、この構成が実運用に耐えるかを7項目で実測しました。macOS 26.6、Apple Silicon、releaseビルドで、spawnは30回、tickは1000回のサンプルです(E1bのコールドスタートだけは、ばらつきが大きいためn=60を2回行った結果です)。

#何を測ったか合否ライン結果
E1コールドスタート(検証済みバイナリ)p50 150ms以下p50 5〜6ms
E1bコールドスタート(未検証バイナリ)p95 500ms以下p50 164〜224ms、p95 317〜342ms
E2往復レイテンシ(50タブ相当のスナップショット)p95 5ms以下p95 0.27ms
E3逆方向capability RPCの上乗せ差分を報告+0.020ms(in-process semaphoreは+0.008ms)
E4kill検知から復旧、残存リソースCPU・スレッド・RSSの残存なし8.3ms で新しいヘルパーが応答。残存なし
E5サンドボックス下でのJSC成立とJIT劣化倍率封じ込めが効くこと封じ込め成立、性能コスト約1.00倍。JITはentitlement次第で11.8倍
E6メモリ帰属footprintがJS確保に追随100MB確保で+100.9MiB
E7サンドボックス下でのfd渡し棄却条件ではないファイルは読める、列挙もできる。openatは拒否

E2は、tabを50件持つワークスペースのスナップショットを毎回JSONへ直す想定のペイロードで測っています。往復レイテンシのp95は0.27msで、5msの合否ラインに対して20倍近い余裕があります。

E3は本番に近いペイロードだとJSONエンコードのコストに埋もれてノイズになるため、最小構成のペイロードで測った値を使っています。

E5は App Sandbox を付けたヘルパーで、ホームディレクトリがコンテナへリダイレクトされること、proc_listchildpids がEPERMになること、コンテナ外のcanaryファイルがパス経由で開けないことを確認したうえでの数字です。

E6は、プラグイン側のJSで100MBを確保させ、ホスト側から見たヘルパーpidの footprint がそれに追随するかを見ています。

アイドル状態のヘルパーは1プロセスあたり4.0MiBで、10個常駐させても50MiB弱です。プラグイン1つにつきプロセスを1つ立てる構成は贅沢に見えますが、実際のコストは小さく収まりました。

設計段階で置いていた棄却条件は「コールドスタートのp95が500msを大きく超え、かつpre-warmでも吸収できない場合」でした。E1bのp95は317〜342msで、500msのラインには収まっています。7項目とも棄却には当たらず、方式は成立しました。

そのうえで、想定と違う結果が3つ出ました。

JITはentitlementの有無だけで決まっていた

E5で見たかったのは「サンドボックス下でJSCがどれだけ遅くなるか」でした。実際に起きていたのは、それとは別の話です。

ad-hoc署名だけでサンドボックスもhardened runtimeも付けていない素のヘルパーには、JITがそもそも存在しません。JSCは自分が確保したメモリ領域にVM user tagを付けるので、実行時間から推測するのではなく、ヘルパー自身のVMマップを直接見て確認できます。JSヒープの予約は4096MiBある一方、JITの実行可能領域を確保するアロケーターは0バイトでした。JSCが実行可能ページを作れないので、インタープリターとしてしか動けません。

com.apple.security.cs.allow-jit entitlementを与える(hardened runtimeとApp Sandboxを併用したうえで)と、512MiBのJIT領域が現れ、同じ計算ループが11.8倍速くなりました。ヘルパー1呼び出しあたりの実測は、entitlementなしで15.8ms、ありで1.34msです。呼び出しブリッジ自体のコストは約1.1µsで、entitlementの有無で変わりません。速くなっているのはエンジン側だけです。

entitlementを付けても、コンテナへのリダイレクトも proc_listchildpids のEPERMも、コンテナ外canaryへのアクセス拒否も変わりませんでした。つまり「サンドボックスか性能か」というトレードオフではなく、「entitlementを付け忘れていないか」だけの話でした。

allow-jit entitlementなしのヘルパーは1呼び出しあたり15.8ms、ありのヘルパーは1.34ms。どちらもApp Sandbox配下で、コンテナへのリダイレクトとcanaryへのアクセス拒否は変わらない

out-of-processにする側にとっては、むしろ都合のいい話が増えました。実行可能ページを作れる権限は、アプリ本体ではなくヘルパーだけに与えれば済みます。JITは使えるがコンテナの外は見えず、いつでもkillできるプロセス1つに、権限そのものを閉じ込められる形になりました。

渡されたディレクトリのfdは、中身は見えても開けない

検証前に立てていた仮説は、ホストがディレクトリを開いてそのfdをヘルパーへ渡せば、ヘルパーは openat 経由でそのサブツリーだけを読める、というものでした。パス全体をサンドボックスが拒否していても、記述子越しなら通るはずだという考えです。この推論は検証前のノートに「未測定」と明記していました。

実測は、その仮説を半分だけ裏付けました。渡された1個のファイルのfdは普通に読めます。パスで直接同じファイルを開こうとするとEPERMになるので、fdを渡すことで確かにパスのポリシーが拒む先にもアクセスできています。渡されたディレクトリのfdに対して fdopendir を呼ぶと、中身の列挙にも成功します。

ここで openat(dirfd, "inside.txt") を呼ぶとEPERMになりました。openat は呼ばれるたびにパス解決をやり直すので、サンドボックスは渡された記述子ではなく、解決した後のパスの方を検査します。openat(dirfd, "../canary.txt") による脱出も拒否されましたが、これは記述子側の制約ではなくサンドボックスの働きで、サンドボックスなしの環境では同じ呼び出しがそのまま外まで抜けます。

渡されたファイルのfdはreadが成功する。渡されたディレクトリのfdはfdopendirによる列挙が成功するが、同じfdに対するopenatは中のファイルに対してEPERMになる。パスによる直接openはどちらの場合も拒否される

渡された記述子でできるのは、それが指しているオブジェクトそのものへの操作までです。そこから先のパス解決はできません。ディレクトリの中身は見えるのに、そこにある何一つとして開けない、という中間状態です。

この結果を踏まえると、ファイルを1つ開くたびにホストへ逆方向RPCで問い合わせる形が、脇道ではなく設計の中心になります。E3で測った上乗せは0.020msなので、呼び出し頻度が上がっても支障はありません。サブツリーをまとめて渡す正規の手段には security-scoped bookmark と sandbox extension がありますが、これは今回の検証範囲に入っていません。

コールドスタートは「検証済みのバイナリか」で30倍変わる

E1は最初、同じ検証済みバイナリを30回spawnして「p50 5ms」という結果を出していました。これは、ユーザーが実際に踏む状況のうち一番肝心なケースだけを外して測っていたのだと、後で気づきました。きっかけは無関係な実行で、リビルド直後のバイナリを最初にspawnした1回だけが669msで返ってきたことです。

そこで、毎回新しいパスへコピーしたバイナリでspawnし直すE1bを別に用意しました。ヘルパーをインストールした直後や、アップデートした直後にユーザーが最初に触るプラグインが踏むのと同じ状況です。

2回のn=60実行で、p50は164〜224ms、p95は317〜342msでした。単発では603ms、一度は3.1秒まで伸びています。ヘルパー自体は131KBなので、バイナリのサイズが原因ではありません。

検証済みバイナリを使い回した場合のp50は5〜6ms。毎回新しいパスにコピーしたバイナリのp50は164〜224ms、p95は317〜342msで、設計上の150ms予算を超えるが棄却ラインの500msには収まる

150msという設計上の予算は超えているので、初回起動をクリックの瞬間に任せることはできません。pre-warmが要ります。

500msという棄却ラインの内側には収まっていますが、単発のspawnはそのラインを超えることがあるので、500msを保証値として扱うのは誤りです。定常状態、つまり同じバイナリを2回目以降spawnする分には5〜6msで、pre-warmは要りません。

この約200msの内訳(コード署名の検証なのかdyldの初回処理なのか)は、今回の測定では分解できていません。

測定側にも罠があった

JITの比較をしている最中、測定そのものに2つの罠がありました。

最初に書いたベンチマークスクリプトでは % を使っていて、doubleの %fmod に落ちるので、コンパイルされていてもいなくても同じくらいのコストがかかります。JITが効いていても効いていなくても差が出ないベンチマークになっていました。整数演算と | 0 に置き換えて直しています。

JSC_useJIT=0 を対照実験として素のヘルパーに与えたところ、数値が変化しなかったことで、この罠に気づけました。

同じ環境変数をallow-jitのヘルパーに与えると、JIT領域が0になり、ループは同じ11.8倍だけ遅くなりました。変数はきちんと効いていて、素のヘルパー側は本当にJITが無かったことが分かります。

呼び出しブリッジそのもののコストも、最初は2000回のループを持つスクリプトから読んでいて、「entitlementでブリッジが7倍速くなる」という誤った値を出していました。2000回程度のループでも全体の9割以上を占めていて、実際に見えていたのはエンジン側の速度差でした。

中身を空にした関数を20万回呼ぶ形に直すと、ブリッジ自体のコストは約1.1µsで、entitlementの有無では変わらないと分かりました。