OmoikaneへBoaを取り込む前にTest262を動かしていたところ、未最適化のパーサーがstack overflowでabortするケースが見つかりました。
失敗したのはTest262のregress-672893.jsです。
無効なJavaScriptを何万段も入れ子にしたわけではありません。関数式を15段ほど入れ子にした有効なJavaScriptでした。
構造を省略すると、次のようなコードです。
function f() {
return function () {
return function () {
...同じ形で15段まで続く...
return function (a) {
var v = a;
return function () { return v; };
};
};
};
}
f()()() ... ()(42)();
このコードをdev profileでビルドしたBoaへ渡し、スレッドスタックを2 MiBにすると、パーサーがstack overflowを起こしてexit 134で終了しました。
同じコードでも8 MiBなら通ります。また、JITやJavaScriptの実行中ではなく、ソースコードをASTへ変換する途中で落ちていました。
入れ子1段で約125 KB使っていた
デバッガで関数式の解析フレーム間を測ると、修正前の未最適化aarch64 Linuxでは、入れ子1段あたり125,360 bytes使っていました。
15段なら単純計算で約1.88 MBです。これにパーサー以外の呼び出し、lexer、エラー処理なども加わるため、2 MiBのスタックには収まりません。
原因は、再帰的な式解析が後半の処理で使うASTの一時値を保持したまま、次の関数本体を解析していたことでした。
例えば二項演算子を処理するパーサーでは、まず左辺を解析し、そのあとに続く演算子と右辺を処理します。
左辺を解析する
↓
演算子が続いているか調べる
↓
右辺を解析してASTを組み立てる
関数全体が一つになっていると、後半で使う変数やASTのための領域まで含んだ大きいフレームを作り、その状態で左辺の解析へ入ります。
左辺の中に関数式があり、さらにその中に式があれば、同じような大きいフレームが積み重なります。
releaseビルドでは最適化によって見えにくくなることがありますが、devビルドではそのままスタック使用量に出ていました。
後半の処理を別の関数へ分ける
Boa側の修正では、最初の要素を解析する処理と、その後ろに続く要素を処理する部分を分けました。
形としては次のような変更です。
let lhs = LowerExpression::new(...).parse(cursor, interner)?;
self.parse_tail(lhs, cursor, interner)
#[inline(never)]
fn parse_tail<R: ReadChar>(
self,
mut lhs: ast::Expression,
cursor: &mut Cursor<R>,
interner: &mut Interner,
) -> ParseResult<ast::Expression> {
// 演算子や後続の式を処理する
}
先に呼ぶ解析が終わってからparse_tailへ入るため、後半処理の一時値を再帰中のフレームへ置かずに済みます。
この分離によって、同じ環境で入れ子1段あたりの使用量は125,360 bytesから86,064 bytesまで減りました。
#[inline(never)]を付けているのは、releaseビルドの最適化で関数が再び一つに戻されるのを防ぐためです。
実際、最適化によって後半処理がインライン化されると、releaseでも100段の配列と括弧を解析する回帰テストが失敗しました。devで直ったから終わりではなく、分離した状態を最適化後も維持する必要がありました。
stack overflowの前に通常のエラーを返す
フレームを小さくしても、入力をいくらでも深くできるなら、どこかではスタックを使い切ります。
そのため、すべての文法要素が通るTokenParser::parseを共通の入口にし、スタック使用量と文法解析の呼び出し数を確認するようにしました。
追記(2026-09-10):以下は最初の修正時点の計測方法です。その後ASANのfake stackで誤判定することが分かり、x86_64とARM64ではスタックポインタを直接読む方式へ変更しました。続編の記事に経緯を書いています。
fn parse(self, cursor: &mut Cursor<R>, interner: &mut Interner)
-> ParseResult<Self::Output>
{
let marker = 0_u8;
cursor.enter_parser(std::ptr::from_ref(&marker) as usize, interner)?;
let result = self.parse_inner(cursor, interner);
cursor.leave_parser();
result
}
制限は次の2つです。
const MAX_STACK_BYTES: usize = 1536 * 1024;
const MAX_PARSER_DEPTH: usize = 4096;
一番外側の解析でスタック上のアドレスを記録し、各文法要素へ入るたびに現在位置との差を測ります。
1.5 MiB以上使った場合、または同時に処理している文法要素が4096に達した場合は、プロセスを落とさず次のエラーを返します。
parser recursion limit exceeded
これは4096段のJavaScript関数を許可するという意味ではありません。一つのJavaScript構文を解析するために複数の文法要素を通るため、実際に受け入れられる入れ子の深さはCPU、コンパイラ、build profileなどによって変わります。
また、ソースコードの長さや文の個数を制限するものでもありません。平坦なリストや反復処理で解析できる長い式はそのまま扱えます。
なぜ2 MiBに対して1.5 MiBで止めるのか
パーサーへ入る時点で、ホスト側には最低2 MiBの空きスタックを要求します。
そのうちパーサーの再帰に使える量を1.5 MiBまでにして、残りの512 KiBは次の文法要素、lexer、エラーの生成、呼び出し元まで戻る処理のために残します。
2 MiBのホストスタック
├─ 1.5 MiB: パーサーの再帰で使用可能
└─ 512 KiB: lexer、エラー処理、後始末など
制限へ到達してからエラーを返す場合も、エラーを作ってスタックを巻き戻す余裕が必要です。2 MiBぎりぎりまで解析に使うと、エラーへ変換する途中で再びstack overflowする可能性があります。
パーサー自身がスレッドのスタックサイズを取得したり、拡張したりするわけではありません。呼び出す側が、解析へ入る時点で2 MiB以上の空きを用意する契約です。
この条件と制限の考え方はdocs/parser-stack.mdにも残しました。
Omoikaneからも確認する
Boa単体で通っても、Omoikaneから使ったときにプロセスが落ちるようでは困ります。
Omoikane側の回帰テストでは、2 MiBと8 MiBを明示したスレッドを作り、公開されているJsRuntimeからJavaScriptを評価しています。
std::thread::Builder::new()
.stack_size(stack_bytes)
.spawn(|| {
let mut runtime = JsRuntime::new().expect("runtime initialization");
// 有効な15段の入れ子を実行する
runtime.eval(&source).expect("valid nested functions");
// 過深な入力はSyntaxErrorになる
let error = runtime.eval(&too_deep).expect_err("excessive recursion");
assert!(error.as_native().unwrap().is_syntax());
// エラー後も同じruntimeを使える
assert_eq!(
runtime.eval("6 * 7").unwrap().as_number(),
Some(42.0),
);
});
ここでは次の3点を確認しています。
- 元の有効な入れ子を2 MiBと8 MiBの両方で実行できる
- 過深な入力はabortではなく
SyntaxErrorになる - エラーのあとも同じruntimeで次のJavaScriptを評価できる
単にstack overflowを避けるだけではなく、エラー後にランタイムが壊れていないところまで確認しました。
CPUとbuild profileを分けて試す
最終的にはx86_64とaarch64、それぞれのdevとreleaseを分けた4ジョブで回帰テストを実行しました。
各ジョブでは2 MiBと8 MiBの両方でパーサー単体のテストを動かし、元になったTest262も両方のスタックサイズで実行しています。
4ジョブとも成功し、元のregress-672893.jsもすべての組み合わせでPassedになりました。
Omoikane側もPR #637で修正済みのBoaへ更新し、公開APIの回帰テストを追加しています。こちらも通常テスト、WPT、Gate 4、JIT stressを含む全12チェックが成功しました。
まとめ
今回落ちていたJavaScriptは、仕様上有効で、入れ子も15段程度でした。
問題は単純に再帰回数が多かったことではなく、一つの再帰フレームが未最適化時に約125 KBまで大きくなっていたことでした。
後半処理を別関数へ分けてフレームを小さくし、それでも深すぎる入力には共通入口で上限を設けました。
これで通常の入力は2 MiBのスタックでも解析でき、過深な入力はプロセスをabortさせずSyntaxErrorとして返せます。
releaseビルドだけを見ていると最適化で問題が隠れることがあります。ライブラリとして組み込まれるパーサーなら、未最適化ビルドと小さいホストスタックの組み合わせも試しておいたほうがよさそうです。