前回の記事では、Boaのパーサーが2 MiBのスタックを使い切る問題を修正しました。
再帰中のフレームを小さくし、深すぎる入力にはエラーを返すようにしました。通常のビルドでは、元のTest262も2 MiBで通るようになっています。
その状態でAddressSanitizer(ASAN)を使った診断を進めると、今度は浅い入力まで拒否するようになりました。
parser recursion limit exceeded
スタックを使い切らないように追加した保護が、使い切っていないところで止めていました。Issue #640の話です。
同じ実行ファイルなのに設定を変えると通る
最初に確認したのは、Test262のtest/built-ins/Function/prototype/constructorです。
x86_64 Linux、Rust 1.98.1、ASANを有効にしたreleaseビルドで、workerのスタックは8 MiBありました。
同じ実行ファイルとテストを使い、ASANの設定だけを切り替えたところ、結果が変わりました。
detect_stack_use_after_return | 結果 |
|---|---|
1 | 0/1。assert.jsの読み込み時に再帰制限へ到達 |
0 | 1/1。normalとstrictの両方で成功 |
比較ログは当時のCI runのartifactにあるcalibration-1.logとcalibration-0.logに残しています。
テスト本体の複雑な処理どころか、テスト用のassert.jsを読み込む段階で止まっています。前回の深い入れ子とは少し様子が違いました。
ローカル変数のアドレスで測っていた
前回追加したスタック使用量の検査では、解析の入口で小さなローカル変数を作り、そのアドレスを記録していました。
let marker = 0_u8;
cursor.enter_parser(std::ptr::from_ref(&marker) as usize, interner)?;
一番外側の解析で記録したアドレスと、現在の解析で得たアドレスの差を、スタック使用量として扱う方式です。
外側の解析にあるmarkerのアドレス
↓ アドレスの差
再帰先の解析にあるmarkerのアドレス
ここには、両方のmarkerが同じnative stack上に置かれているという前提があります。
ASANのfake stackを有効にすると、この前提が崩れました。
fake stackでは変数が別の場所に置かれる
ASANには、関数から戻ったあとで、その関数のローカル変数を参照してしまう不具合を検出する機能があります。stack-use-after-returnの検査です。
通常のスタック領域は、関数から戻ると次の呼び出しで再利用されます。そこでASANは検査対象のローカル変数をfake stackという別の領域へ配置し、関数が終わったあとにその領域へのアクセスを検出できるようにします。ASANの設計文書に仕組みが説明されています。
この配置では、ローカル変数のアドレス同士が遠く離れていても、その分だけnative stackを消費したとは限りません。
今回の実装は、その距離をそのまま使用量として扱っていました。浅い解析でも1.5 MiBを超えたと判定され、保護処理がエラーを返していたわけです。
ASANが不正アクセスを報告して止めたのではなく、ASANによって変わった変数配置をパーサー側が読み違えていました。
スタックポインタを直接読む
Boa PR #89では、x86_64とARM64について、CPUのスタックポインタを直接読む方式に変更しました。
x86_64ではrspを読みます。
let address: usize;
unsafe {
core::arch::asm!(
"mov {}, rsp",
out(reg) address,
options(nomem, nostack, preserves_flags)
);
}
ARM64ではspを読みます。
let address: usize;
unsafe {
core::arch::asm!(
"mov {}, sp",
out(reg) address,
options(nomem, nostack, preserves_flags)
);
}
どちらもスタックポインタの値を出力レジスタへコピーするだけです。ポインタが指すメモリへアクセスしたり、スタックポインタを書き換えたりはしません。
これなら、ローカル変数がfake stackへ移されてもnative stackの現在位置を取得できます。最初の位置との差を取る考え方はそのままで、測定する値を変えました。
取得用の関数は#[inline(always)]にしています。未最適化時にも、計測用関数自身のフレームではなく、呼び出した解析処理の位置を読むためです。
実装はparser/stack.rsにあります。ほかのアーキテクチャとMiriでは従来のローカル変数を使う処理を残していて、fake stack対応を検証したのはx86_64とARM64です。
計測を直したあともフレームの分離が必要だった
計測の誤りを直すと、ASANを有効にしたreleaseビルドで、既存の100段の配列と括弧のテストが通らない問題も見えてきました。
ASANの検査用コードが入ることで、実際のフレームも大きくなります。測定対象を直せば、スタック消費そのものも小さくなるという話ではありませんでした。
そこで前回と同じように、再帰先の解析中には必要ない処理を別関数へ分けました。今回は関係演算子、primary expression、left-hand-side expression、括弧の後続処理などが対象です。
1.5 MiBのスタック使用量と4096個の同時文法解析という上限は維持しています。100段の入力も減らさず、ASANを有効にした状態で通るようにしました。
ホスト側が解析入口で2 MiB以上の空きスタックを用意する条件も同じです。今回直接読んでいるのは現在位置であって、OSからスタック全体の残量を取得しているわけではありません。
fake stackを有効にしたままテストする
調査途中ではdetect_stack_use_after_return=0として、ほかのヒープ検査を先に進めていました。ただ、その実行結果にはstack-use-after-returnの検査は含まれません。
今回追加したASAN用のCIでは、設定を明示しています。
ASAN_OPTIONS=detect_leaks=0:detect_stack_use_after_return=1:abort_on_error=1
この診断ではfake stackを有効にし、リーク検査は無効にしています。何を有効にした結果なのか分かるよう、設定もartifactへ保存します。
通常ビルドとASANビルドについて、x86_64とARM64、それぞれのdevとreleaseを分けて確認しました。
| CPU | profile | 通常のパーサーCI | ASANのパーサーCI |
|---|---|---|---|
| x86_64 | dev | 成功 | 成功 |
| x86_64 | release | 成功 | 成功 |
| ARM64 | dev | 成功 | 成功 |
| ARM64 | release | 成功 | 成功 |
通常の4ジョブとASANの4ジョブが成功しています。ASAN側のdevにはCARGO_PROFILE_DEV_OPT_LEVEL=1を指定しており、前回扱った未最適化devとは条件が異なります。
ASAN側では、2 MiBスレッドで空文字列や42;などの浅い入力を解析するテストも追加しました。script、module、eval、動的な関数生成、UTF-16、Reader経由と、入口を変えて確認しています。
各ASANジョブでパーサーの5テストが通り、元のFunction constructorのTest262も2 MiBと8 MiBの両方で1/1になりました。深すぎる入力の拒否と、releaseでの100段の配列・括弧のテストも維持しています。
なお、このTest262 runnerはテストが失敗してもプロセスの終了コードが0になる場合があります。CIでは終了コードだけで判断せず、結果JSONの成功件数と、ソース・Test262のrevisionまで確認しています。
前の記事の計測方法はここで変更した
修正はBoa PR #89でマージし、OmoikaneにもPR #653で取り込みました。Issue #640は統合後にクローズしています。
前の記事に載せたローカル変数のアドレスによる計測は、最初の修正段階のものです。x86_64とARM64では、今回の修正でnative stack pointerを読む方式へ変わりました。
スタック使用量を見ているつもりでしたが、実際に見ていたのはローカル変数の置き場所でした。
通常のビルドで成り立っていた前提が、ASANの検査を入れたら成り立たなくなったという話でした。