Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
33 changes: 29 additions & 4 deletions crates/perry-codegen/src/expr/i32_fast_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -687,16 +687,39 @@ fn try_lower_expr_native_i32_structural(ctx: &mut FnCtx<'_>, e: &Expr) -> Result
}
}
Expr::Uint8ArrayGet { array, index } => {
Some(super::arrays_finds::lower_uint8array_get_i32(ctx, array, index)?.value)
let lowered = super::arrays_finds::lower_uint8array_get_i32(ctx, array, index)?;
Some(i32_from_indexed_get_lowered(ctx, lowered))
}
Expr::BufferIndexGet { buffer, index } => {
Some(super::arrays_finds::lower_buffer_index_get_i32(ctx, buffer, index)?.value)
let lowered = super::arrays_finds::lower_buffer_index_get_i32(ctx, buffer, index)?;
Some(i32_from_indexed_get_lowered(ctx, lowered))
}
_ => None,
};
Ok(value)
}

/// Bridge an indexed-get helper's `LoweredValue` into a guaranteed-i32 SSA
/// value. `lower_uint8array_get_i32`'s unproven-key escape (the mysql2
/// MockBuffer probe fix in `arrays_finds.rs`) returns the polymorphic
/// property read as a boxed JS VALUE (`F64` rep). The i32-context callers
/// here used to grab `.value` blindly and label that double register `i32`,
/// emitting malformed IR — `error: '%rN' defined with type 'double' but
/// expected 'i32'` — which the pi bundle hit (#6593) once its inliner
/// frontier left a `buf[k]` index insufficiently proven-numeric. Apply the
/// JS `ToInt32(ToNumber(v))` bridge instead.
fn i32_from_indexed_get_lowered(ctx: &mut FnCtx<'_>, lowered: LoweredValue) -> String {
match lowered.rep {
NativeRep::I32 | NativeRep::U32 => lowered.value,
_ => {
let number = ctx
.block()
.call(DOUBLE, "js_number_coerce", &[(DOUBLE, &lowered.value)]);
ctx.block().toint32(&number)
}
}
}

fn lower_packed_i32_loop_index_get(ctx: &mut FnCtx<'_>, e: &Expr) -> Result<Option<LoweredValue>> {
let Expr::IndexGet { object, index } = e else {
return Ok(None);
Expand Down Expand Up @@ -1052,10 +1075,12 @@ fn lower_expr_native_i32(ctx: &mut FnCtx<'_>, e: &Expr) -> Result<LoweredValue>
}
}
Expr::Uint8ArrayGet { array, index } => {
super::arrays_finds::lower_uint8array_get_i32(ctx, array, index)?.value
let lowered = super::arrays_finds::lower_uint8array_get_i32(ctx, array, index)?;
i32_from_indexed_get_lowered(ctx, lowered)
}
Expr::BufferIndexGet { buffer, index } => {
super::arrays_finds::lower_buffer_index_get_i32(ctx, buffer, index)?.value
let lowered = super::arrays_finds::lower_buffer_index_get_i32(ctx, buffer, index)?;
i32_from_indexed_get_lowered(ctx, lowered)
}
// Fallback for other expressions.
_ => {
Expand Down
71 changes: 71 additions & 0 deletions crates/perry-transform/src/inline/call_inliner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,18 @@ pub fn inline_calls_in_stmts(
enclosing_class: Option<&str>,
class_field_types: &HashMap<(String, String), String>,
) {
// Same cap as `inline_calls_in_expr`, sharing one thread-local depth
// budget: the two functions are mutually recursive, and a stmts-side
// chain (nested blocks, closure bodies, re-walks of freshly inlined
// bodies) that never passes through the expr-side guard would otherwise
// recurse unbounded — half of #6593. Bailing out skips further inlining
// in this subtree, which is semantics-preserving (the inliner is an
// optimization pass); clear `exact_receiver_facts` because we no longer
// track what the skipped statements may reference or mutate.
let Some(_recursion_guard) = enter_inline_expr_recursion() else {
exact_receiver_facts.clear();
return;
};
let mut i = 0;
while i < stmts.len() {
// Track local variable types from Let statements
Expand Down Expand Up @@ -1980,4 +1992,63 @@ mod tests {
.join()
.unwrap();
}

/// Deeply nested `if (…) { leaf } else { <next level> }` chain — the
/// #6593 shape. Unlike the linear closure fixture above, every level
/// makes MULTIPLE guarded descents (condition expr, then-branch stmts,
/// else-branch stmts), which is exactly what made the pi bundle
/// unbounded twice over:
/// 1. `inline_calls_in_stmts` had no guard at all, so the
/// stmts→stmts else-chain recursed once per level; and
/// 2. even with the stmts guard, the eager
/// `then_some(InlineExprRecursionGuard)` refunded a depth unit on
/// every bail, so the condition's bail at cap let the else-branch
/// descend one level deeper — forever.
fn nested_if_else_chain(depth: usize) -> Vec<Stmt> {
let mut stmts = vec![Stmt::Expr(Expr::Integer(0))];
for _ in 0..depth {
stmts = vec![Stmt::If {
condition: Expr::Integer(1),
then_branch: vec![Stmt::Expr(Expr::Integer(2))],
else_branch: Some(stmts),
}];
}
stmts
}

#[test]
fn inline_stmts_skips_extremely_deep_branching_stmt_trees() {
// Deep enough that one stack frame per level overflows the 32MB
// test stack if either the stmts-entry guard is missing or the
// guard's bail path refunds budget (both variants crashed here
// before the #6593 fix); shallow enough that the fixture's own
// recursive drop glue stays far from the limit.
const DEPTH: usize = MAX_INLINE_EXPR_RECURSION_DEPTH * 200;
std::thread::Builder::new()
.stack_size(32 * 1024 * 1024)
.spawn(|| {
let mut stmts = nested_if_else_chain(DEPTH);
let mut local_types = HashMap::new();
let mut exact_receiver_facts = ExactReceiverFacts::new();
let mut next_local_id = 1;

inline_calls_in_stmts(
&mut stmts,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
&mut local_types,
&mut exact_receiver_facts,
&mut next_local_id,
None,
&HashMap::new(),
);

assert_eq!(stmts.len(), 1);
assert!(exact_receiver_facts.is_empty());
})
.unwrap()
.join()
.unwrap();
}
}
115 changes: 108 additions & 7 deletions crates/perry-transform/src/inline/super_detect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,26 @@ use std::cell::Cell;

pub(crate) const MAX_INLINE_EXPR_RECURSION_DEPTH: usize = 128;

/// Per-top-level-walk budget of guarded entries (see
/// `enter_inline_expr_recursion`). The depth cap alone bounds STACK, not
/// WORK: with candidate-subtree cloning at every Conditional/Logical/
/// Sequence node and re-walks of freshly inlined bodies, a single huge
/// generated function can keep the walk churning within the depth limit
/// for hours (observed on #6593's 13.3MB esbuild bundle: the pass ran
/// 2h45m+ CPU-bound in clone glue after the stack fix, where it previously
/// crashed in ~90s). Once a walk has consumed this many entries, further
/// entries bail — the identical semantics-preserving skip as the depth
/// bail. One entry is roughly one visited stmt-list/expr node, so ordinary
/// bodies (even tens of thousands of nodes) keep full inlining coverage;
/// only generated-code monsters hit the cap, and those merely lose
/// inlining of the subtrees beyond it.
pub(crate) const MAX_INLINE_WALK_WORK: usize = 250_000;

thread_local! {
static INLINE_EXPR_RECURSION_DEPTH: Cell<usize> = const { Cell::new(0) };
/// Guarded entries consumed by the current top-level walk. Reset when a
/// new walk starts (an entry at depth 0); never refunded on unwind.
static INLINE_WALK_WORK: Cell<usize> = const { Cell::new(0) };
}

pub(crate) struct InlineExprRecursionGuard;
Expand All @@ -24,16 +42,44 @@ impl Drop for InlineExprRecursionGuard {
}

pub(crate) fn enter_inline_expr_recursion() -> Option<InlineExprRecursionGuard> {
let entered = INLINE_EXPR_RECURSION_DEPTH.with(|depth| {
// The guard must only ever exist when the increment actually happened.
// The previous `entered.then_some(InlineExprRecursionGuard)` constructed
// the guard EAGERLY (`then_some` takes its argument by value), so on the
// bail path `then_some` dropped that guard — and its `Drop` decremented a
// depth unit this call never took. Every bail refunded one level of
// budget, so any AST node that makes two or more sibling recursive
// descents (Conditional then/else, a freshly-inlined-result re-walk, …)
// could burn the first sibling's bail to push the next sibling one level
// deeper, forever: the cap stopped bounding recursion at all (#6593, the
// 13.3MB pi bundle overflowed a 512MB stack this way). Linear chains —
// like the #733 nested-closure regression test — never exposed this,
// because with a single descent per level there is no later sibling to
// spend the refunded budget.
INLINE_EXPR_RECURSION_DEPTH.with(|depth| {
let current = depth.get();
if current == 0 {
// New top-level walk (Phase 4 init / a Phase 5 function body / a
// Phase 6 method body): fresh work budget.
INLINE_WALK_WORK.with(|work| work.set(0));
}
if current >= MAX_INLINE_EXPR_RECURSION_DEPTH {
false
} else {
depth.set(current + 1);
true
return None;
}
});
entered.then_some(InlineExprRecursionGuard)
let over_work_budget = INLINE_WALK_WORK.with(|work| {
let used = work.get();
if used >= MAX_INLINE_WALK_WORK {
true
} else {
work.set(used + 1);
false
}
});
if over_work_budget {
return None;
}
depth.set(current + 1);
Some(InlineExprRecursionGuard)
})
}

fn expr_contains_lexical_super(expr: &Expr) -> bool {
Expand Down Expand Up @@ -132,3 +178,58 @@ fn stmt_contains_lexical_super(stmt: &Stmt) -> bool {
pub(crate) fn method_contains_lexical_super(method: &Function) -> bool {
method.body.iter().any(stmt_contains_lexical_super)
}

#[cfg(test)]
mod tests {
use super::*;

/// #6593 regression: a failed entry (cap hit) must NOT refund depth
/// budget. With the old eager `then_some(InlineExprRecursionGuard)`, the
/// bail path dropped an eagerly-built guard, decrementing the counter it
/// never incremented — so the attempt right after a bail wrongly
/// succeeded, and branching recursion could descend without bound.
#[test]
fn bail_does_not_refund_depth_budget() {
let guards: Vec<InlineExprRecursionGuard> = (0..MAX_INLINE_EXPR_RECURSION_DEPTH)
.map(|_| enter_inline_expr_recursion().expect("budget not yet exhausted"))
.collect();
assert!(
enter_inline_expr_recursion().is_none(),
"entry at cap must bail"
);
assert!(
enter_inline_expr_recursion().is_none(),
"a bail must not refund budget: the next attempt at cap must bail too"
);
drop(guards);
assert!(
enter_inline_expr_recursion().is_some(),
"budget must be restored once real guards unwind"
);
}

/// #6593 companion: the depth cap bounds stack but not total work — a
/// walk that keeps entering/unwinding within the depth limit must
/// eventually exhaust a per-walk work budget and bail, and a NEW
/// top-level walk (entry at depth 0) must start with a fresh budget.
#[test]
fn work_budget_is_spent_per_walk_and_resets_at_top_level() {
// Hold one outer guard so the walk stays "in progress" (depth >= 1)
// while we burn the budget with enter/unwind churn.
let outer = enter_inline_expr_recursion().expect("fresh walk must enter");
for _ in 0..MAX_INLINE_WALK_WORK - 1 {
let inner = enter_inline_expr_recursion();
assert!(inner.is_some(), "within budget, entries must succeed");
drop(inner);
}
assert!(
enter_inline_expr_recursion().is_none(),
"an exhausted work budget must bail even though depth unwound"
);
drop(outer);
assert!(
enter_inline_expr_recursion().is_some(),
"a new top-level walk must reset the work budget"
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,37 @@ pub(super) fn detect_optional_feature_usage(
}
}

// #6593 (pi bundle) — detect name-heuristic native package lowering.
// `detect_native_instance_expr` routes `new LRUCache(...)` / `new
// Decimal(...)` / `new Command(...)` etc. to a native module by BINDING
// NAME, with no import statement required. An esbuild bundle that
// inlines such a package (pi's hosted-git-info inlines `lru-cache`)
// therefore emits `NativeMethodCall { module: "lru-cache", … }` calls
// while `native_module_imports` never learns about the module — the
// per-binding perry-stdlib feature stays off and the link dies with
// undefined `_js_lru_cache_*` symbols (from `GitHost.fromUrl`). Same
// failure mode and fix as the EventEmitter block above. Scan classes
// too: the pi call sites live in a static method body, which the
// init+functions-only scans miss.
{
let hir_debug: String = format!(
"{:?}{:?}{:?}",
&hir_module.init, &hir_module.functions, &hir_module.classes
);
for native_module in [
"lru-cache",
"big.js",
"decimal.js",
"bignumber.js",
"commander",
] {
if hir_debug.contains(&format!("module: \"{native_module}\"")) {
ctx.needs_stdlib = true;
ctx.native_module_imports.insert(native_module.to_string());
}
}
}

// Detect WHATWG URL API usage. The `url`+`idna` host-canonicalization
// engine (~195 KB) is gated behind `perry-runtime/url-engine`; Perry's URL
// parsing is otherwise hand-rolled, so a program with no URL API links none
Expand Down
39 changes: 37 additions & 2 deletions crates/perry/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,10 +268,45 @@ fn main() -> Result<()> {
// large codebases. Bumped from 64 MB in v0.5.973 — ioredis-via-
// compilePackages (~30 transitive CJS modules) overflowed 64 MB in the
// collect/lower walk on the perry-main thread.
//
// PERRY_MAIN_STACK_MB overrides the size (in MB) without a rebuild —
// useful both for diagnosing suspected-unbounded recursion (bump it and
// see whether the overflow moves, #6593) and as an escape hatch for
// legitimately deep inputs that outgrow the default (already happened
// twice: v0.5.973, #6593). Invalid or zero values fall back to 128;
// values above 16384 (16 GB) clamp so the MB→bytes conversion cannot
// overflow usize and the spawn below cannot fail on an absurd request.
const MAX_STACK_MB: usize = 16 * 1024;
let stack_mb: usize = std::env::var("PERRY_MAIN_STACK_MB")
.ok()
.and_then(|v| v.trim().parse().ok())
.filter(|mb| *mb > 0)
.map(|mb: usize| {
if mb > MAX_STACK_MB {
eprintln!(
"perry: PERRY_MAIN_STACK_MB={mb} exceeds the {MAX_STACK_MB} MB ceiling; clamping"
);
MAX_STACK_MB
} else {
mb
}
})
.unwrap_or(128);
let stack_bytes = stack_mb
.checked_mul(1024 * 1024)
.expect("stack size in bytes fits usize (clamped above)");
let builder = std::thread::Builder::new()
.name("perry-main".into())
.stack_size(128 * 1024 * 1024);
let handler = builder.spawn(main_inner).unwrap();
.stack_size(stack_bytes);
let handler = match builder.spawn(main_inner) {
Ok(handler) => handler,
Err(err) => {
return Err(anyhow::anyhow!(
"failed to spawn the perry-main compiler thread \
(stack size {stack_mb} MB; try a smaller PERRY_MAIN_STACK_MB): {err}"
));
}
};
match handler.join() {
Ok(result) => result,
Err(panic_payload) => {
Expand Down
Loading