diff --git a/src/js_parser/parse/parse_jsx.rs b/src/js_parser/parse/parse_jsx.rs index c40e9917b55..083595de43d 100644 --- a/src/js_parser/parse/parse_jsx.rs +++ b/src/js_parser/parse/parse_jsx.rs @@ -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 { let p = self; + // Nested child elements (`...`) 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; } diff --git a/test/bundler/transpiler/jsx-deep-nesting-stack-overflow.test.ts b/test/bundler/transpiler/jsx-deep-nesting-stack-overflow.test.ts new file mode 100644 index 00000000000..9510e4e3497 --- /dev/null +++ b/test/bundler/transpiler/jsx-deep-nesting-stack-overflow.test.ts @@ -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 +// (`...`), but unlike the other recursive parse entry points it never +// consulted the parser's stack guard. A source like `() =>
` repeated +// thousands of times nests that many `
` 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 `() =>
` 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 = "() =>
"; + 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); +});