From 4b4b591289a9e1debbe25b4410df21fb0ad697f6 Mon Sep 17 00:00:00 2001 From: Ralph Date: Tue, 16 Jun 2026 04:27:23 -0700 Subject: [PATCH] fix(lower): guard deeply-nested expression chains against stack overflow (#5259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expression lowering (`lower_expr`) recurses once per node, so a left-nested `1+1+…+1`, `o.a.a.…a`, or `a||a||…` chain — shapes bundlers/minifiers emit — recurses thousands of frames deep and overflows the native stack, aborting the process with `fatal runtime error: stack overflow` (SIGABRT, exit 134) and no diagnostic. Add a recursion-depth guard at the `lower_expr` entry point: a per-module counter on `LoweringContext`, incremented on entry and decremented on every exit path. Past `MAX_EXPR_LOWER_DEPTH` (2000) lowering bails via `lower_bail!` with a span-tagged "expression nested too deeply" diagnostic instead of recursing further. The compiler lowers on a 128 MB stack and the heaviest shape (member chains) uses ~16 KB/level, so 2000 caps worst-case lowering at ~32 MB — well under the limit and far above anything real code emits, so only the degenerate inputs that would otherwise crash are rejected. Tests cover all three shapes plus an under-limit chain that must still lower; they run on a 128 MB-stack thread (mirroring `perry-main`) because the default ~2 MB cargo-test stack can't hold the multi-thousand-node parse/lower walk. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/perry-hir/src/lower/context.rs | 1 + crates/perry-hir/src/lower/lower_expr.rs | 40 +++++++++++ .../perry-hir/src/lower/lowering_context.rs | 6 ++ crates/perry-hir/src/lower/tests.rs | 70 +++++++++++++++++++ 4 files changed, 117 insertions(+) diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index a6bf3a5d3b..a42cf55feb 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -155,6 +155,7 @@ impl LoweringContext { is_external_module: false, optional_require_try_depth: 0, fn_ctor_env: super::fn_ctor_env::FnCtorEnv::default(), + expr_lower_depth: 0, } } diff --git a/crates/perry-hir/src/lower/lower_expr.rs b/crates/perry-hir/src/lower/lower_expr.rs index d94e8a2338..5ae2de1ffe 100644 --- a/crates/perry-hir/src/lower/lower_expr.rs +++ b/crates/perry-hir/src/lower/lower_expr.rs @@ -16,12 +16,30 @@ use anyhow::{anyhow, Result}; use perry_types::{LocalId, Type}; +use swc_common::Spanned; use swc_ecma_ast as ast; use super::*; use crate::ir::*; use crate::lower_types::extract_ts_type_with_ctx; +/// Maximum `lower_expr` recursion depth before lowering bails with a +/// diagnostic instead of overflowing the native stack (#5259). +/// +/// Expression lowering is recursive: a left-nested `a+b+c+…` chain, an +/// `o.a.a.…a` member chain, or an `a||a||…` logical chain each recurses once +/// per operator/segment. Bundler/minifier output occasionally emits chains +/// thousands of nodes deep; left unguarded these overflow the stack and +/// SIGABRT (exit 134) with no diagnostic at all. The compiler runs its +/// collect/lower walk on a 128 MB stack (`perry-main`, see `crates/perry/ +/// src/main.rs`), and the heaviest shape (member chains) consumes on the +/// order of ~16 KB of stack per level, so this ceiling keeps worst-case +/// lowering depth well under ~32 MB — far below the stack limit — while still +/// sitting far above anything hand-written code or a reasonable build emits. +/// The only inputs it rejects are the degenerate ones that would otherwise +/// crash, and they now get a clean "nested too deeply" diagnostic instead. +pub(crate) const MAX_EXPR_LOWER_DEPTH: u32 = 2000; + fn class_computed_member_registration_expr(class_name: &str, member: &ClassComputedMember) -> Expr { match member.kind { ClassComputedMemberKind::Method => Expr::RegisterClassComputedMethod { @@ -473,6 +491,28 @@ pub(crate) fn native_module_binding_value(ctx: &LoweringContext, name: &str) -> } pub(crate) fn lower_expr(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result { + // #5259: guard the recursive descent. Without this, a pathologically + // nested expression (`1+1+…`, `o.a.a.…`, `a||a||…`) overflows the native + // stack and SIGABRTs with no diagnostic. The depth counter turns that into + // a clean "nested too deeply" error. It is decremented on every exit path, + // including the error returns inside `lower_expr_impl`, so a recoverable + // lowering error elsewhere doesn't leave the depth permanently inflated. + ctx.expr_lower_depth += 1; + if ctx.expr_lower_depth > MAX_EXPR_LOWER_DEPTH { + ctx.expr_lower_depth -= 1; + crate::lower_bail!( + expr.span(), + "expression nested too deeply (exceeded {} levels); split the \ + chain across statements or intermediate variables", + MAX_EXPR_LOWER_DEPTH + ); + } + let result = lower_expr_impl(ctx, expr); + ctx.expr_lower_depth -= 1; + result +} + +fn lower_expr_impl(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result { match expr { ast::Expr::Lit(lit) => lower_lit(lit), ast::Expr::Ident(ident) => { diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index 8f76971f15..169b8ed8ae 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -609,4 +609,10 @@ pub struct LoweringContext { /// object literals, counters). Built once per module in /// `lower_module_full`; consumed by `const_fold_fn`. pub(crate) fn_ctor_env: super::fn_ctor_env::FnCtorEnv, + /// Current recursion depth of `lower_expr` (#5259). Incremented on entry, + /// decremented on exit. Once it exceeds `MAX_EXPR_LOWER_DEPTH`, lowering + /// bails with a clean "nested too deeply" diagnostic instead of letting a + /// pathologically-nested expression chain (bundler/minifier output like + /// `1+1+…+1` or `o.a.a.…a`) overflow the native stack and SIGABRT. + pub(crate) expr_lower_depth: u32, } diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index 7dd4c50bca..6df1695650 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -257,3 +257,73 @@ fn test_lower_namespace_var_lookup() { assert_eq!(ctx.lookup_namespace_var("Utils", "missing"), None); assert_eq!(ctx.lookup_namespace_var("Other", "helper"), None); } + +/// Run `f` on a thread with the same large (128 MB) stack the real compiler +/// uses for its collect/lower walk (`perry-main`, see `crates/perry/src/ +/// main.rs`). The default cargo-test harness thread is only ~2 MB, which is +/// far too small to parse or lower the multi-thousand-node chains these +/// `#5259` tests build — without this, parsing/lowering them would overflow +/// the *test* stack before the depth guard ever fires. +fn run_with_large_stack(f: F) { + std::thread::Builder::new() + .stack_size(128 * 1024 * 1024) + .spawn(f) + .expect("spawn large-stack thread") + .join() + .expect("test body panicked"); +} + +/// #5259: deeply-nested expression chains must surface a diagnostic instead +/// of overflowing the native stack and SIGABRT-ing the whole process. Each +/// shape (binary `1+1+…`, member `o.a.a.…`, logical `a||a||…`) recurses once +/// per node in `lower_expr`; past `MAX_EXPR_LOWER_DEPTH` lowering bails with a +/// "nested too deeply" error rather than recursing further. +fn assert_too_deep(source: String) { + run_with_large_stack(move || { + let module = + perry_parser::parse_typescript(&source, "deep.ts").expect("source should parse fine"); + let err = super::lower_module(&module, "deep", "deep.ts") + .expect_err("deeply-nested expression must be rejected, not lowered"); + let msg = format!("{err}"); + assert!( + msg.contains("nested too deeply"), + "expected a depth diagnostic, got: {msg}" + ); + }); +} + +#[test] +fn test_lower_rejects_deep_binary_chain() { + let n = (super::lower_expr::MAX_EXPR_LOWER_DEPTH as usize) * 3; + let chain: Vec<&str> = vec!["1"; n]; + assert_too_deep(format!("var x = {};\n", chain.join("+"))); +} + +#[test] +fn test_lower_rejects_deep_member_chain() { + let n = (super::lower_expr::MAX_EXPR_LOWER_DEPTH as usize) * 3; + assert_too_deep(format!("var o = {{}};\nvar x = o{};\n", ".a".repeat(n))); +} + +#[test] +fn test_lower_rejects_deep_logical_chain() { + let n = (super::lower_expr::MAX_EXPR_LOWER_DEPTH as usize) * 3; + let chain: Vec<&str> = vec!["a"; n]; + assert_too_deep(format!("var a = 0;\nvar x = {};\n", chain.join("||"))); +} + +/// A chain comfortably under the ceiling still lowers cleanly — the guard +/// must not reject ordinary (if large) expressions. +#[test] +fn test_lower_accepts_chain_under_limit() { + run_with_large_stack(|| { + let n = (super::lower_expr::MAX_EXPR_LOWER_DEPTH as usize) / 2; + let chain: Vec<&str> = vec!["1"; n]; + let source = format!("var x = {};\n", chain.join("+")); + let module = perry_parser::parse_typescript(&source, "ok.ts").expect("parses"); + assert!( + super::lower_module(&module, "ok", "ok.ts").is_ok(), + "a chain under the depth ceiling must lower without error" + ); + }); +}