While running Test262 before importing Boa into Omoikane, we discovered a case where an unoptimized parser aborts due to a stack overflow (issue #633).
The failure occurred in Test262’s regress-672893.js.
It wasn’t a matter of nesting tens of thousands of invalid JavaScript statements. It was valid JavaScript with about fifteen levels of nested function expressions.
If we omit the structural details, the code looks like this:
function f() {
return function () {
return function () {
...continues in the same pattern up to 15 steps...
return function (a) {
var v = a;
return function () { return v; };
};
};
};
}
f()()() ... ()(42)();
When this code is passed to a Boa built with the dev profile and the thread stack is set to 2 MiB, the parser triggers a stack overflow and exits with status 134.
The same code works fine when the stack size is 8 MiB. Also, the crash occurred not during JIT compilation or JavaScript execution, but while converting the source code into an AST.
About 125 KB per nesting level
Measuring the frames used for parsing function expressions with a debugger, we found that on the unoptimized aarch64 Linux build before the fix, each level of nesting consumed 125,360 bytes.
Fifteen levels would therefore use roughly 1.88 MB in a simple calculation. In addition, calls outside the parser—such as the lexer and error handling—also consume stack, so the total exceeds the 2 MiB stack limit.
The cause was that the recursive expression parser retained temporary AST values needed for later processing while continuing to parse the next function body.
For example, in a parser that handles binary operators, it first parses the left‑hand side, then processes the operator and the right‑hand side that follow.
Parse the left-hand side
↓
Check whether an operator continues
↓
Parse the right-hand side and build the AST
When the entire function is treated as a single unit, a large frame is created that includes space for variables and AST nodes needed later, and parsing then proceeds into the left‑hand side with that frame in place.
If the left‑hand side contains a function expression, and that expression itself contains further sub‑expressions, similarly large frames are stacked on top of each other.
In a release build, optimizations can obscure this issue, but in a dev build the stack usage appears directly.
Splitting the later processing into a separate function
In the Boa‑side fix (PR #83), we separated the processing of the first element from the part that handles the subsequent elements.
The change looks like this:
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> {
// Process the operator and the following expression
}
Because we enter parse_tail only after the initial parsing has completed, the temporary values needed for the later processing no longer have to be stored in the recursive frames.
This separation reduced the per‑nesting‑level memory usage from 125,360 bytes to 86,064 bytes in the same environment.
The #[inline(never)] marker is added to prevent the release‑build optimizer from merging the functions back into a single one.
In fact, when the later processing was inlined by optimization, the regression test that parses a 100‑element array with parentheses failed even in release builds. Simply fixing it in dev was not enough; we had to ensure that the separated structure remained after optimization.
Return a normal error before a stack overflow
Even if the frames are made smaller, if the input can be arbitrarily deep, the stack will eventually be exhausted.
Therefore, we made TokenParser::parse, which handles all grammar productions, the common entry point and added checks for stack usage and the number of grammar‑parsing calls.
Addendum (2026‑09‑10): The measurement method described below was used at the time of the initial fix. Later we discovered that ASAN’s fake‑stack could produce false positives, so we switched to a method that reads the stack pointer directly on x86_64 and ARM64. The background is documented in the follow‑up article (related article).
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
}
There are two limits:
const MAX_STACK_BYTES: usize = 1536 * 1024;
const MAX_PARSER_DEPTH: usize = 4096;
We record the stack address at the outermost parsing stage, and each time we enter a new grammar element we measure the difference from the current position.
If more than 1.5 MiB of stack is used, or if the number of simultaneously active grammar elements reaches 4096, we return an error instead of terminating the process.
parser recursion limit exceeded
This does not mean that 4096 levels of JavaScript functions are allowed. Because parsing a single JavaScript construct may traverse multiple grammar elements, the actual nesting depth that can be accepted varies depending on the CPU, compiler, build profile, and other factors.
It also does not impose limits on source‑code length or the number of statements. Long expressions that can be parsed as a flat list or via iterative processing are handled without issue.
Why stop at 1.5 MiB when the stack is 2 MiB
When the parser is entered, the host is required to have at least 2 MiB of free stack.
We reserve up to 1.5 MiB of that for the parser’s recursion, leaving the remaining 512 KiB for the next grammar element, the lexer, error generation, and the work needed to return back to the caller.
2 MiB host stack
├─ 1.5 MiB: available for parser recursion
└─ 512 KiB: lexer, error handling, cleanup, etc.
Even when an error is returned after reaching the limit, there must be enough stack space left to create the error and unwind the stack. If the parser uses almost the entire 2 MiB, converting the error could itself cause another stack overflow.
The parser does not itself query or enlarge the thread’s stack size; it is the caller’s responsibility to provide at least 2 MiB of free stack when parsing begins.
This condition and the associated limits are also documented in docs/parser-stack.md.
Verify from Omoikane as well
Even if Boa passes on its own, it would be problematic if the process crashes when used from Omoikane.
In the Omoikane regression tests (source code), we create threads explicitly set to 2 MiB and 8 MiB, and evaluate JavaScript using the publicly exposed JsRuntime.
std::thread::Builder::new()
.stack_size(stack_bytes)
.spawn(|| {
let mut runtime = JsRuntime::new().expect("runtime initialization");
// Evaluate valid 15-level nesting
runtime.eval(&source).expect("valid nested functions");
// Excessively deep input produces SyntaxError
let error = runtime.eval(&too_deep).expect_err("excessive recursion");
assert!(error.as_native().unwrap().is_syntax());
// The same runtime remains usable after the error
assert_eq!(
runtime.eval("6 * 7").unwrap().as_number(),
Some(42.0),
);
});
In this test we verify three things:
- The original valid nesting can be executed successfully with both 2 MiB and 8 MiB stack sizes.
- Excessively deep inputs do not cause an abort but instead produce
SyntaxError. - After an error, the same runtime can still evaluate the next JavaScript code.
We not only avoided a stack overflow, but also confirmed that the runtime remains intact after an error.
Testing across CPUs and build profiles
In the end, we ran regression tests with four jobs, covering x86_64 and aarch64, each in both dev and release builds.
Each job executed the parser‑only tests with both 2 MiB and 8 MiB stack sizes, and also ran the original Test262 with both stack sizes.
All four jobs succeeded (CI run), and the original regress-672893.js passed for every combination.
On the Omoikane side, we updated to the Boa version fixed in PR #637 (PR #637) and added regression tests for the public API. These also passed all twelve checks, including the regular test suite, WPT, Gate 4, and JIT stress tests.
Summary
The JavaScript that caused the crash was valid according to the specification, with only about fifteen levels of nesting.
The problem was not simply a high recursion count, but that a single recursion frame grew to roughly 125 KB in the unoptimized build.
We split the later processing into a separate function to reduce the frame size, and we imposed a limit on excessively deep inputs via a common entry point.
With this, normal inputs can be parsed even with a 2 MiB stack, and excessively deep inputs can be returned as SyntaxError without aborting the process.
If you only look at release builds, optimization can sometimes hide problems. For a parser that will be linked as a library, it’s advisable to test it with an unoptimized build combined with a small host stack as well.