From 146422ad80abb08d38c964018ee60e0ca3a77d8c Mon Sep 17 00:00:00 2001 From: Ralph Date: Tue, 16 Jun 2026 04:15:37 -0700 Subject: [PATCH] =?UTF-8?q?fix(lower):=20linear-time=20inference=20for=20n?= =?UTF-8?q?ested=20literals=20=E2=80=94=20cap=20recursion=20depth=20(#5258?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lowering a deeply-nested object literal was O(n²). `infer_type_from_expr` is re-run on the *current* value at every nesting level during lowering, and its Object/Array/Arrow arms recursed into the entire remaining subtree, so per-level work scaled with the remaining depth. An 8000-deep literal stalled `check-lower` for ~24s; a 13 MB minified bundle (wall-to-wall deeply-nested object literals) never finished. Cap the inference recursion at depth 48. Past the cap it returns Type::Any — the universal sound fallback the codebase already relies on (codegen routes Any through the tag-aware paths), so this only drops type *precision* far past any realistic source nesting, never correctness. The top-level type tag (hence anon-shape dedup) is unchanged. Per-level cost is now bounded, so lowering is linear in depth: 8000-deep drops from 23.8s to 0.20s; 20000-deep finishes in 0.49s. Regression test lowers a 6000-deep literal and asserts linear wall-time. Note: nested arrow chains (`()=>()=>…`) show a milder super-linear shape from a *separate* root cause — closure capture analysis (compute_closure_captures) re-walks nested closure bodies at every level — left for a follow-up since it touches correctness-sensitive (dayjs/Effect) capture code. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/perry-hir/src/lower_types.rs | 31 +++++++++++++++ crates/perry-hir/tests/shape_inference.rs | 47 +++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/crates/perry-hir/src/lower_types.rs b/crates/perry-hir/src/lower_types.rs index 7ae2011242..8e95ee3b30 100644 --- a/crates/perry-hir/src/lower_types.rs +++ b/crates/perry-hir/src/lower_types.rs @@ -262,7 +262,38 @@ fn url_encoding_constructor_type(ctx: &LoweringContext, callee: &ast::Expr) -> O } } +/// Max recursion depth for `infer_type_from_expr`. Beyond this the inference +/// degrades to `Type::Any` (the universal sound fallback — see the +/// `Array`/`Bin` arms below, where `Any` simply selects the tag-aware codegen +/// path). This bounds the per-call cost so lowering a deeply-nested literal +/// stays linear: lowering descends one nesting level at a time and re-infers +/// the *current* value's type at each level, so an uncapped per-call cost of +/// O(remaining subtree) made the whole pass O(n²) — #5258 (an 8000-deep object +/// literal or `()=>()=>…` arrow chain stalled `check-lower` for minutes). Real +/// source never nests literals this deep, so the cap loses no practical +/// precision while keeping pathological/minified inputs tractable. +const INFER_TYPE_RECURSION_CAP: u32 = 48; + pub(crate) fn infer_type_from_expr(expr: &ast::Expr, ctx: &LoweringContext) -> Type { + thread_local! { + static INFER_DEPTH: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + struct DepthGuard; + impl Drop for DepthGuard { + fn drop(&mut self) { + INFER_DEPTH.with(|d| d.set(d.get().saturating_sub(1))); + } + } + let depth = INFER_DEPTH.with(|d| { + let v = d.get(); + d.set(v + 1); + v + }); + let _depth_guard = DepthGuard; + if depth >= INFER_TYPE_RECURSION_CAP { + return Type::Any; + } + match expr { // Literals ast::Expr::Lit(lit) => match lit { diff --git a/crates/perry-hir/tests/shape_inference.rs b/crates/perry-hir/tests/shape_inference.rs index 1e88864399..77afa1e37f 100644 --- a/crates/perry-hir/tests/shape_inference.rs +++ b/crates/perry-hir/tests/shape_inference.rs @@ -782,3 +782,50 @@ fn standalone_inheritance_preserves_chain() { assert!(derived.extends.is_some()); assert!(derived.fields.iter().any(|f| f.name == "d")); } + +/// Regression for #5258: lowering a deeply-nested object literal must be +/// LINEAR in nesting depth. `infer_type_from_expr` is re-run on the *current* +/// value at every nesting level during lowering, and its `Object` arm used to +/// recurse into the entire remaining subtree — making the whole pass O(n²) (an +/// 8000-deep literal stalled `check-lower` for ~24s; a 13 MB minified bundle +/// never finished). The recursion-depth cap in `infer_type_from_expr` bounds +/// the per-level cost, restoring linear scaling. +/// +/// The wall-clock bound is intentionally generous (the fixed path lowers this +/// in well under a second; the pre-fix O(n²) path took double-digit seconds at +/// this depth) so the test stays robust on loaded CI while still failing loudly +/// if quadratic behavior is reintroduced. +#[test] +fn nested_object_literal_lowers_in_linear_time() { + const DEPTH: usize = 6000; + let src = format!("var x = {}0{};\n", "{a:".repeat(DEPTH), "}".repeat(DEPTH)); + + // Lower on a generously-sized stack — deeply-nested lowering is recursive. + let src_owned = src.clone(); + let start = std::time::Instant::now(); + let module = std::thread::Builder::new() + .stack_size(128 * 1024 * 1024) + .spawn(move || { + let mut cache = SourceCache::new(); + let parsed = parse_typescript_with_cache(&src_owned, "test.ts", &mut cache) + .expect("parse should succeed"); + lower_module(&parsed.module, "test", "test.ts").expect("lower should succeed") + }) + .expect("spawn lower thread") + .join() + .expect("lower thread panicked"); + let elapsed = start.elapsed(); + + assert!( + elapsed.as_secs() < 5, + "lowering a {DEPTH}-deep object literal took {elapsed:?}; expected linear (<5s). \ + A regression likely reintroduced O(n²) subtree work in expression-type inference.", + ); + + // The outermost binding's type tag is still inferred as an object (the + // cap only degrades types *past* the depth limit to Any). + assert!( + matches!(find_local_type(&module, "x"), Type::Object(_)), + "outermost nested-object binding should infer Type::Object", + ); +}