From 2127a1530c91ccf945ed7d9daa26f94a8810cb73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 18 Jul 2026 17:49:02 +0200 Subject: [PATCH 1/5] =?UTF-8?q?fix(transform):=20bound=20all=20call-inline?= =?UTF-8?q?r=20recursion=20=E2=80=94=20pi-bundle=20stack=20overflow=20(#65?= =?UTF-8?q?93)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent holes let the inline pass recurse without bound on the 13.3MB unminified pi coding-agent bundle, overflowing even a 512MB perry-main stack: 1. enter_inline_expr_recursion() built its RAII guard EAGERLY via bool::then_some(InlineExprRecursionGuard). On the bail path then_some drops that eagerly-constructed guard, and its Drop impl decrements a depth unit the call never took — every cap-hit refunded one level of budget. Any node making two or more sibling guarded descents (Conditional then/else, the freshly-inlined-result re-walk) burned the first sibling's bail to push the next sibling one level deeper, forever: the #733 cap stopped bounding recursion entirely. The #733 nested-closure regression test is a linear chain (single descent per level), so no later sibling ever existed to spend the refund. Fix: construct the guard inside the success branch only. 2. inline_calls_in_stmts had no guard at all, so stmts-side chains (nested blocks, closure bodies, re-walks of freshly inlined bodies) recursed once per level regardless of the expr-side cap. Fix: mirror the guard at the stmts entry, sharing the same thread-local budget; on cap, clear exact_receiver_facts and leave the subtree un-inlined (semantics-preserving — same rationale as the expr-side bail). Also adds PERRY_MAIN_STACK_MB to override the perry-main stack size without a rebuild (default stays 128MB) — the constant has now been outgrown twice (v0.5.973 ioredis, #6593 diagnosis). Tests: - super_detect: bail_does_not_refund_depth_budget — fails against the eager then_some (second post-bail attempt wrongly succeeds). - call_inliner: inline_stmts_skips_extremely_deep_branching_stmt_trees — a 25.6k-deep if/else chain; overflows a 32MB thread if EITHER hole is present (verified both ways), passes with both fixed. Fixes #6593 Claude-Session: https://claude.ai/code/session_01JuiiePQfrXhAFD9fuCygB9 --- .../src/inline/call_inliner.rs | 71 +++++++++++++++++++ .../src/inline/super_detect.rs | 52 ++++++++++++-- crates/perry/src/main.rs | 13 +++- 3 files changed, 130 insertions(+), 6 deletions(-) diff --git a/crates/perry-transform/src/inline/call_inliner.rs b/crates/perry-transform/src/inline/call_inliner.rs index 95ff52e321..c726dcd004 100644 --- a/crates/perry-transform/src/inline/call_inliner.rs +++ b/crates/perry-transform/src/inline/call_inliner.rs @@ -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 @@ -1980,4 +1992,63 @@ mod tests { .join() .unwrap(); } + + /// Deeply nested `if (…) { leaf } else { }` 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 { + 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(); + } } diff --git a/crates/perry-transform/src/inline/super_detect.rs b/crates/perry-transform/src/inline/super_detect.rs index b3ac8ffe9e..0449f73e63 100644 --- a/crates/perry-transform/src/inline/super_detect.rs +++ b/crates/perry-transform/src/inline/super_detect.rs @@ -24,16 +24,28 @@ impl Drop for InlineExprRecursionGuard { } pub(crate) fn enter_inline_expr_recursion() -> Option { - 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 >= MAX_INLINE_EXPR_RECURSION_DEPTH { - false + None } else { depth.set(current + 1); - true + Some(InlineExprRecursionGuard) } - }); - entered.then_some(InlineExprRecursionGuard) + }) } fn expr_contains_lexical_super(expr: &Expr) -> bool { @@ -132,3 +144,33 @@ 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 = (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" + ); + } +} diff --git a/crates/perry/src/main.rs b/crates/perry/src/main.rs index 9bf12473a6..faa0454e1f 100644 --- a/crates/perry/src/main.rs +++ b/crates/perry/src/main.rs @@ -268,9 +268,20 @@ 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. + let stack_mb: usize = std::env::var("PERRY_MAIN_STACK_MB") + .ok() + .and_then(|v| v.trim().parse().ok()) + .filter(|mb| *mb > 0) + .unwrap_or(128); let builder = std::thread::Builder::new() .name("perry-main".into()) - .stack_size(128 * 1024 * 1024); + .stack_size(stack_mb * 1024 * 1024); let handler = builder.spawn(main_inner).unwrap(); match handler.join() { Ok(result) => result, From 8de395b377f1741b7c946a153f5e8f7b57d6d5ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 18 Jul 2026 20:49:46 +0200 Subject: [PATCH 2/5] fix(codegen): respect LoweredValue rep in i32 indexed-get bridges (#6593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lower_uint8array_get_i32's unproven-key escape (the mysql2 MockBuffer probe fix) returns the polymorphic property read as a boxed JS VALUE (F64 rep) — but all four i32-context callers grabbed `.value` blindly and labeled the double register i32, emitting malformed LLVM IR: error: '%rN' defined with type 'double' but expected 'i32' The pi bundle hit this once the inliner's new work budget left a `buf[k]` index insufficiently proven-numeric. Bridge through ToInt32(ToNumber(v)) (js_number_coerce + toint32) when the helper returns a non-i32 rep instead. Claude-Session: https://claude.ai/code/session_01JuiiePQfrXhAFD9fuCygB9 --- .../perry-codegen/src/expr/i32_fast_path.rs | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/crates/perry-codegen/src/expr/i32_fast_path.rs b/crates/perry-codegen/src/expr/i32_fast_path.rs index fa380120dc..bc0b0074e8 100644 --- a/crates/perry-codegen/src/expr/i32_fast_path.rs +++ b/crates/perry-codegen/src/expr/i32_fast_path.rs @@ -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> { let Expr::IndexGet { object, index } = e else { return Ok(None); @@ -1052,10 +1075,12 @@ fn lower_expr_native_i32(ctx: &mut FnCtx<'_>, e: &Expr) -> Result } } 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. _ => { From 73fb108a61cfd6b6b7efadb89efe05748bbc2799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 18 Jul 2026 20:49:46 +0200 Subject: [PATCH 3/5] fix(compile): enable per-binding stdlib features for name-heuristic native lowering (#6593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detect_native_instance_expr routes `new LRUCache(...)` / `new Decimal(...)` / `new Command(...)` etc. to a native module by BINDING NAME, 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 #5140 EventEmitter block: token-grep the lowered HIR for `module: ""` of the name-heuristic packages and union them into native_module_imports. Scans classes too — the pi call sites live in a static method body, which the init+functions-only scans miss. Claude-Session: https://claude.ai/code/session_01JuiiePQfrXhAFD9fuCygB9 --- .../compile/collect_modules/feature_detect.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/perry/src/commands/compile/collect_modules/feature_detect.rs b/crates/perry/src/commands/compile/collect_modules/feature_detect.rs index fd654b5a3a..289e8175eb 100644 --- a/crates/perry/src/commands/compile/collect_modules/feature_detect.rs +++ b/crates/perry/src/commands/compile/collect_modules/feature_detect.rs @@ -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 From bf4591d3775e9c52c1d4c7378226876b0f381a73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 18 Jul 2026 20:50:05 +0200 Subject: [PATCH 4/5] fix(transform): per-walk work budget on the inliner guard (#6593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The depth cap bounds STACK, not WORK: with candidate-subtree cloning at every Conditional/Logical/Sequence node and re-walks of freshly inlined bodies, the pi bundle's generated functions kept the pass churning within the depth limit — 2h45m+ CPU-bound in clone glue after the stack fix (observed live: every sampled stack at near-cap depth with clone leaves), where the pass previously crashed in ~90 seconds. Give the shared guard a per-top-level-walk budget of entries (reset when a walk starts at depth 0, never refunded); once spent, further entries take the exact same semantics-preserving bail as the depth cap. Ordinary bodies stay far below the 250k cap and keep full inlining coverage; generated-code monsters lose only inlining of the subtrees beyond the frontier. With this, the pi bundle's whole compile completes in ~22 minutes. Claude-Session: https://claude.ai/code/session_01JuiiePQfrXhAFD9fuCygB9 --- .../src/inline/super_detect.rs | 67 +++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/crates/perry-transform/src/inline/super_detect.rs b/crates/perry-transform/src/inline/super_detect.rs index 0449f73e63..6ae66c4d03 100644 --- a/crates/perry-transform/src/inline/super_detect.rs +++ b/crates/perry-transform/src/inline/super_detect.rs @@ -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 = 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 = const { Cell::new(0) }; } pub(crate) struct InlineExprRecursionGuard; @@ -39,12 +57,28 @@ pub(crate) fn enter_inline_expr_recursion() -> Option // 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 { - None - } else { - depth.set(current + 1); - Some(InlineExprRecursionGuard) + return None; } + 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) }) } @@ -173,4 +207,29 @@ mod tests { "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" + ); + } } From 6f6b9c8c90680924f368a4b6ea0045e883a57f87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 18 Jul 2026 21:03:09 +0200 Subject: [PATCH 5/5] fix(cli): clamp PERRY_MAIN_STACK_MB and handle spawn failure (CodeRabbit on #6603) Claude-Session: https://claude.ai/code/session_01JuiiePQfrXhAFD9fuCygB9 --- crates/perry/src/main.rs | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/perry/src/main.rs b/crates/perry/src/main.rs index faa0454e1f..b3a8841bca 100644 --- a/crates/perry/src/main.rs +++ b/crates/perry/src/main.rs @@ -273,16 +273,40 @@ fn main() -> Result<()> { // 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. + // 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(stack_mb * 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) => {