Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/js_parser/parse/parse_jsx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ use bun_core::err;
impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_ONLY> {
pub fn parse_jsx_element(&mut self, loc: bun_ast::Loc) -> Result<Expr, bun_core::Error> {
let p = self;
// Nested child elements (`<a><b><c>...`) recurse back into this function,
// so guard the stack the same way the other recursive parse entry points do.
if !p.stack_check.is_safe_to_recurse() {
return Err(err!("StackOverflow"));
}
if SCAN_ONLY {
p.needs_jsx_import = true;
}
Expand Down
59 changes: 59 additions & 0 deletions test/bundler/transpiler/jsx-deep-nesting-stack-overflow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";

// Regression test for a stack overflow in the TSX parser, found by fuzzing.
//
// `parse_jsx_element` recurses directly for every nested child element
// (`<a><b><c>...`), but unlike the other recursive parse entry points it never
// consulted the parser's stack guard. A source like `() => <div>` repeated
// thousands of times nests that many `<div>` children (the `() =>` between each
// pair is parsed as JSX text), so the unbounded recursion ran off the end of
// the stack and the process died on the guard page with a bare SIGSEGV — no
// crash handler, no error message.
//
// With the guard in place the parser stops and reports "Maximum call stack size
// exceeded" instead of crashing. The transpile runs in a child process so a
// regression fails these assertions rather than taking down the test runner.
test("deeply nested arrow/JSX does not overflow the stack", async () => {
// Each `() => <div>` adds one arrow frame and one JSX-element frame. This is
// far deeper than the fuzzer's ~23k repetitions so the guard fires well
// before the real stack end on both release and the larger debug frames.
// (`Buffer.alloc` fill over `.repeat` — the latter is very slow in debug JSC.)
const unit = "() => <div>";
const source = Buffer.alloc(unit.length * 50_000, unit).toString();

using dir = tempDir("jsx-deep-nesting-stack-overflow", {
"input.tsx": source,
"run.ts": `
const src = require("node:fs").readFileSync("input.tsx", "latin1");
try {
new Bun.Transpiler({
loader: "tsx",
target: "bun",
minifyWhitespace: true,
deadCodeElimination: true,
}).transformSync(src);
console.log("NO ERROR");
} catch (e) {
console.error(String((e as Error).message));
}
`,
});

await using proc = Bun.spawn({
cmd: [bunExe(), "run.ts"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

// Must terminate on its own, not be killed by the stack-guard-page SIGSEGV.
expect(proc.signalCode).toBeNull();
// The parser bounds the recursion and throws a catchable SyntaxError.
expect(stderr).toContain("Maximum call stack size exceeded");
expect(stdout).not.toContain("NO ERROR");
expect(exitCode).toBe(0);
});
Loading