diff --git a/crates/perry-codegen/src/codegen/entry_outline.rs b/crates/perry-codegen/src/codegen/entry_outline.rs index 1382421208..d77cba26eb 100644 --- a/crates/perry-codegen/src/codegen/entry_outline.rs +++ b/crates/perry-codegen/src/codegen/entry_outline.rs @@ -1,4 +1,4 @@ -//! Structured module-entry outlining (#8595). +//! Structured module-entry outlining (#8595, extended by #10575). //! //! The module top level is lowered into a single LLVM function (`@main` / //! `perry_module_init`). For a large minified bundle that one function is @@ -18,6 +18,59 @@ //! forces it for testing and measurement; `=0` disables it. Top-level await //! and a module-level TDZ preallocation remain fail-safe exclusions because a //! raw module-global load cannot yet perform the checked TDZ-box read. +//! +//! ## #10575: CommonJS module bodies live outside `hir.init` +//! +//! For a CommonJS source file, `cjs_wrap::wrap_commonjs_for_target` wraps the +//! whole body as *text* — `const _cjs = (function() { function +//! __perry_cjs_factory() { } return +//! __perry_cjs_factory(); })();` — before the normal parse/lower pipeline +//! ever sees it. Ordinary lowering represents the named, lexically-nested +//! `function __perry_cjs_factory() {...}` declaration the same way it would +//! an arrow/function EXPRESSION (it is not a top-level declaration, so it is +//! not hoisted into `hir.functions`): a `Stmt::Let` inside the wrapper's +//! outer IIFE, naming an `Expr::Closure`. `hir.init` itself holds only the +//! handful of statements the wrapper adds at module scope (the `_cjs` +//! binding, `export default`, …). Admission above therefore never fires for +//! CJS: the tens of thousands of real statements are not top-level HIR +//! statements anywhere, let alone in `hir.init`. +//! +//! [`outline_cjs_factory_module`] closes that gap: [`find_cjs_factory_closure_mut`] +//! walks `hir.init`'s statement/expression tree to locate that nested +//! closure, and the identical chunking transform +//! ([`chunk_statements`]/[`analyze_stmts_outlining`], same admission +//! thresholds, same fail-safe gates, same `__perry_entry_chunk_*` naming) +//! runs against ITS body instead of `hir.init`'s. New chunks are still +//! ordinary `hir.functions` entries, so `is_entry_chunk` and +//! `emit_module_globals`'s existing "referenced from a separate function +//! body" escape analysis pick them up for free. The one addition is +//! [`logical_outlined_function_stmts`]/the generalised +//! [`outlined_entry_global_let_ids`] residual scan, which teach the global- +//! promotion pass to also look for cross-chunk `var`s inside an outlined +//! factory body, not just inside `hir.init`. A module is only ever outlined +//! from ONE origin per compile (`hir.init` OR the factory, never both), so +//! there is no cross-origin interaction to reason about. +//! +//! The factory is only treated as a virtual module entry when it has the +//! exact wrap-generated shape: no params, not async/generator, and every id +//! it captures from its enclosing scope (the wrapper's outer IIFE) resolves +//! to a `Let` directly in that scope — see [`is_cjs_factory_shape`]. In +//! practice the factory always captures exactly one such id, its own name: +//! the wrapper's preamble does `__cjs_module.__perry_cjs_factory = +//! __perry_cjs_factory;`, a load-bearing self-reference `perry-runtime`'s +//! `module_require.rs` calls through on a circular-require recovery path. +//! Since a chunk is a plain, non-capturing `hir.functions` entry, it cannot +//! read a captured id the way the original (unsplit) closure could — so +//! [`classify_for_chunking`] keeps every statement that references one of +//! the factory's captured ids inline in the residual body (never relocated +//! into a chunk), preserving the exact closure-capture read codegen already +//! provides. This is deliberately NOT solved by promoting the captured id to +//! a module global the way an ordinary cross-chunk `hir.init` let is: a +//! global is one program-wide instance, but a captured id is fresh per +//! closure invocation — promoting it would silently break the recovery path +//! above if it ever re-invokes the factory closure. `wrap_commonjs_for_target` +//! never produces a factory outside this shape, so failing the shape check +//! is a defensive exit, not an expected one. use std::collections::HashSet; @@ -121,6 +174,51 @@ pub(crate) fn is_entry_chunk(function: &perry_hir::Function) -> bool { && !function.is_exported } +/// Every entry-chunk function in `hir.functions`, indexed by id. Chunks are +/// origin-agnostic — this map does not distinguish a chunk split from +/// `hir.init` from one split from an outlined function body (#10575). +fn chunk_map(hir: &HirModule) -> std::collections::HashMap { + hir.functions + .iter() + .filter(|function| is_entry_chunk(function)) + .map(|function| (function.id, function)) + .collect() +} + +/// Is `stmt` a bare, no-argument call to one of `chunks`? The shape +/// [`chunk_statements`] emits for every chunk call site. +fn as_chunk_call<'a>( + stmt: &perry_hir::Stmt, + chunks: &std::collections::HashMap, +) -> Option<&'a perry_hir::Function> { + match stmt { + perry_hir::Stmt::Expr(perry_hir::Expr::Call { callee, args, .. }) if args.is_empty() => { + match callee.as_ref() { + perry_hir::Expr::FuncRef(id) => chunks.get(id).copied(), + _ => None, + } + } + _ => None, + } +} + +/// Reconstruct the source-order statement stream of `stmts` after outlining: +/// every chunk-call site is replaced by that chunk's body, in place. +fn logical_stmts_of<'a>( + stmts: &'a [perry_hir::Stmt], + chunks: &std::collections::HashMap, +) -> Vec<&'a perry_hir::Stmt> { + let mut logical = Vec::new(); + for stmt in stmts { + if let Some(chunk) = as_chunk_call(stmt, chunks) { + logical.extend(chunk.body.iter()); + } else { + logical.push(stmt); + } + } + logical +} + /// Reconstruct the source-order module-entry statement stream after outlining. /// /// Several codegen analyses intentionally inspect module declarations rather @@ -128,33 +226,219 @@ pub(crate) fn is_entry_chunk(function: &perry_hir::Function) -> bool { /// static-field deduplication, and early `process.env` assignments). Replacing /// a range with a chunk call must not hide those original statements from the /// analyses. Non-chunk calls and all inline statements are returned unchanged. +/// +/// This only ever looks at `hir.init` — the module's own top level. A +/// CommonJS factory body outlined by [`outline_cjs_factory_module`] is a +/// different logical scope (see [`logical_outlined_function_stmts`]) and is +/// intentionally NOT included here: callers of this function want "the +/// module's own top-level declarations", which for a CJS-wrapped unit +/// genuinely is just the wrapper's handful of statements. pub fn logical_entry_stmts(hir: &HirModule) -> Vec<&perry_hir::Stmt> { - let chunks: std::collections::HashMap = hir - .functions - .iter() - .filter(|function| is_entry_chunk(function)) - .map(|function| (function.id, function)) - .collect(); - let mut logical = Vec::new(); - for stmt in &hir.init { - let chunk = match stmt { - perry_hir::Stmt::Expr(perry_hir::Expr::Call { callee, args, .. }) - if args.is_empty() => + logical_stmts_of(&hir.init, &chunk_map(hir)) +} + +/// Literal name `cjs_wrap::wrap_commonjs_for_target` gives the CJS module +/// factory closure it generates (`crates/perry/src/commands/compile/cjs_wrap/ +/// wrap.rs`: `function __perry_cjs_factory() { ... }`). +const CJS_FACTORY_NAME: &str = "__perry_cjs_factory"; + +/// If `expr` is itself an `Expr::Closure`, or a same-expression invocation of +/// one (`Expr::Call { callee: Box, .. }` — the +/// `(function(){...})()` IIFE shape `wrap_commonjs_for_target` always uses to +/// wrap a CJS body), return that closure. `None` for anything else — this is +/// intentionally narrow rather than a fully general expression search; the +/// wrapper's shape is fixed and known. +fn as_inline_closure(expr: &perry_hir::Expr) -> Option<&perry_hir::Expr> { + match expr { + perry_hir::Expr::Closure { .. } => Some(expr), + perry_hir::Expr::Call { callee, .. } => as_inline_closure(callee.as_ref()), + _ => None, + } +} + +/// Mutable twin of [`as_inline_closure`]. +fn as_inline_closure_mut(expr: &mut perry_hir::Expr) -> Option<&mut perry_hir::Expr> { + match expr { + perry_hir::Expr::Closure { .. } => Some(expr), + perry_hir::Expr::Call { callee, .. } => as_inline_closure_mut(callee.as_mut()), + _ => None, + } +} + +/// Is `closure` the exact shape `wrap_commonjs_for_target` generates for +/// `__perry_cjs_factory`: no params, an ordinary (non-async, non-generator) +/// function? `outer_body` is its enclosing scope (the wrapper's outer IIFE +/// body) — every id `closure` captures must be defined by a `Stmt::Let` +/// directly in `outer_body`, so [`outline_cjs_factory_module`] can always +/// find where a captured id comes from. (In practice `outer_body` has +/// exactly one such `Let` — the factory's own name — because the wrapper's +/// preamble does `__cjs_module.__perry_cjs_factory = __perry_cjs_factory;`, +/// a load-bearing self-reference `perry-runtime`'s `module_require.rs` calls +/// through for a circular-require recovery path; that is why the factory is +/// NOT required to capture nothing, unlike an ordinary outlining candidate.) +/// `wrap_commonjs_for_target` never produces anything outside this shape, so +/// failing the check is a defensive exit, not an expected one. +fn is_cjs_factory_shape( + params: &[perry_hir::Param], + captures: &[u32], + is_async: bool, + is_generator: bool, + outer_body_let_ids: &HashSet, +) -> bool { + if !params.is_empty() || is_async || is_generator { + return false; + } + captures.iter().all(|id| outer_body_let_ids.contains(id)) +} + +/// The ids directly `Stmt::Let`-defined in `body` (one level, not recursive +/// — exactly what a closure's own `captures` list can name from this scope). +fn top_level_let_ids(body: &[perry_hir::Stmt]) -> HashSet { + body.iter() + .filter_map(|stmt| match stmt { + perry_hir::Stmt::Let { id, .. } => Some(*id), + _ => None, + }) + .collect() +} + +/// Find the `__perry_cjs_factory` closure inside `init` (`hir.init`), if +/// `init` has the exact shape `wrap_commonjs_for_target` generates: a +/// top-level statement whose value is an immediately-invoked closure (the +/// wrapper's outer anonymous IIFE), one of whose OWN direct statements is a +/// `let __perry_cjs_factory = function() { ... }`-shaped binding (JS lowers +/// the wrapper's named `function __perry_cjs_factory() {...}` declaration to +/// exactly this: a `Stmt::Let` naming an `Expr::Closure`, not a top-level +/// `hir.functions` entry, because it is lexically nested inside the IIFE). +fn find_cjs_factory_closure(init: &[perry_hir::Stmt]) -> Option<&perry_hir::Expr> { + for stmt in init { + let outer_init = match stmt { + perry_hir::Stmt::Let { + init: Some(init), .. + } => init, + perry_hir::Stmt::Expr(expr) => expr, + _ => continue, + }; + let Some(perry_hir::Expr::Closure { + body: outer_body, .. + }) = as_inline_closure(outer_init) + else { + continue; + }; + let outer_body_let_ids = top_level_let_ids(outer_body); + for inner in outer_body { + let perry_hir::Stmt::Let { + name, + init: Some(init), + .. + } = inner + else { + continue; + }; + if name != CJS_FACTORY_NAME { + continue; + } + if let perry_hir::Expr::Closure { + params, + captures, + is_async, + is_generator, + .. + } = init { - match callee.as_ref() { - perry_hir::Expr::FuncRef(id) => chunks.get(id).copied(), - _ => None, + if is_cjs_factory_shape( + params, + captures, + *is_async, + *is_generator, + &outer_body_let_ids, + ) { + return Some(init); } } - _ => None, + } + } + None +} + +/// Mutable twin of [`find_cjs_factory_closure`]. Takes `&mut hir.init` +/// specifically (not `&mut HirModule`) so callers can hold this borrow while +/// independently borrowing `hir.functions` to append new chunk functions — +/// a function taking the whole module would make the borrow checker treat +/// every field as borrowed for as long as the returned reference lives. +fn find_cjs_factory_closure_mut(init: &mut [perry_hir::Stmt]) -> Option<&mut perry_hir::Expr> { + for stmt in init.iter_mut() { + let outer_init = match stmt { + perry_hir::Stmt::Let { + init: Some(init), .. + } => init, + perry_hir::Stmt::Expr(expr) => expr, + _ => continue, }; - if let Some(chunk) = chunk { - logical.extend(chunk.body.iter()); - } else { - logical.push(stmt); + let Some(perry_hir::Expr::Closure { + body: outer_body, .. + }) = as_inline_closure_mut(outer_init) + else { + continue; + }; + // Computed once as an OWNED set (not borrowed from `outer_body`) so + // the shape check below doesn't alias the `iter_mut()` that follows + // it — `outer_body`'s own `Let` ids don't change during this scan. + let outer_body_let_ids = top_level_let_ids(outer_body); + for inner in outer_body.iter_mut() { + let perry_hir::Stmt::Let { + name, + init: Some(init), + .. + } = inner + else { + continue; + }; + if name != CJS_FACTORY_NAME { + continue; + } + let shape_ok = matches!( + init, + perry_hir::Expr::Closure { params, captures, is_async, is_generator, .. } + if is_cjs_factory_shape(params, captures, *is_async, *is_generator, &outer_body_let_ids) + ); + if shape_ok { + return Some(init); + } } } - logical + None +} + +/// The CJS factory's residual body, if it was itself outlined this compile +/// (#10575) — i.e. it now contains at least one call to an entry chunk. +/// `None` for every module that isn't a large CJS bundle, and always `None` +/// when `hir.init` itself was the one outlined (a module is only ever +/// outlined from one origin). +fn outlined_factory_residual_body(hir: &HirModule) -> Option<&[perry_hir::Stmt]> { + let perry_hir::Expr::Closure { body, .. } = find_cjs_factory_closure(&hir.init)? else { + return None; + }; + let chunks = chunk_map(hir); + if body + .iter() + .any(|stmt| as_chunk_call(stmt, &chunks).is_some()) + { + Some(body) + } else { + None + } +} + +/// Like [`logical_entry_stmts`], but for the outlined CJS factory body +/// instead of `hir.init` (#10575). Empty unless [`outline_cjs_factory_module`] +/// actually outlined something this compile. +pub(crate) fn logical_outlined_function_stmts(hir: &HirModule) -> Vec<&perry_hir::Stmt> { + let chunks = chunk_map(hir); + match outlined_factory_residual_body(hir) { + Some(body) => logical_stmts_of(body, &chunks), + None => Vec::new(), + } } /// Moved declarations whose storage crosses a generated-function boundary. @@ -229,21 +513,30 @@ pub(crate) fn outlined_entry_global_let_ids(hir: &HirModule) -> HashSet { } let chunk_ids: HashSet = chunks.iter().map(|function| function.id).collect(); - for stmt in &hir.init { - match stmt { - perry_hir::Stmt::PreallocateBoxes(ids) => { - globals.extend(ids.iter().filter(|id| definer.contains_key(id)).copied()); - } - perry_hir::Stmt::Expr(perry_hir::Expr::Call { callee, args, .. }) - if args.is_empty() - && matches!(callee.as_ref(), perry_hir::Expr::FuncRef(id) if chunk_ids.contains(id)) => - { - // The compiler-owned call itself carries no module-local use. - } - _ => { - let mut refs = HashSet::new(); - collect_ref_ids_in_stmts(std::slice::from_ref(stmt), &mut refs); - globals.extend(refs.into_iter().filter(|id| definer.contains_key(id))); + // Residual bodies to scan for must-stay statements that reference a + // chunk-defined let: `hir.init` (always) plus any function body that was + // ITSELF outlined by `outline_cjs_factory_module` (#10575) — today at + // most the CJS factory, never both origins in the same compile. + let residual_bodies: Vec<&[perry_hir::Stmt]> = std::iter::once(hir.init.as_slice()) + .chain(outlined_factory_residual_body(hir)) + .collect(); + for body in residual_bodies { + for stmt in body { + match stmt { + perry_hir::Stmt::PreallocateBoxes(ids) => { + globals.extend(ids.iter().filter(|id| definer.contains_key(id)).copied()); + } + perry_hir::Stmt::Expr(perry_hir::Expr::Call { callee, args, .. }) + if args.is_empty() + && matches!(callee.as_ref(), perry_hir::Expr::FuncRef(id) if chunk_ids.contains(id)) => + { + // The compiler-owned call itself carries no module-local use. + } + _ => { + let mut refs = HashSet::new(); + collect_ref_ids_in_stmts(std::slice::from_ref(stmt), &mut refs); + globals.extend(refs.into_iter().filter(|id| definer.contains_key(id))); + } } } } @@ -278,16 +571,30 @@ pub(crate) fn analyze_entry_outlining(hir: &HirModule) -> EntryOutlineAnalysis { /// Pure analysis for an explicit chunk target — the testable core (no env). fn analyze_entry_outlining_with_target(hir: &HirModule, target: usize) -> EntryOutlineAnalysis { - let stmts = &hir.init; + analyze_stmts_outlining(&hir.init, hir.has_top_level_await, target, &HashSet::new()) +} + +/// Pure statement-list analysis shared by `hir.init` and (#10575) an outlined +/// function body such as the CJS factory — no `HirModule` needed beyond the +/// statements themselves and whether the surrounding scope can suspend across +/// a top-level `await` (only ever true for `hir.init`; a plain function body +/// passes `false`). `must_stay_ids` is the CJS-factory closure's own captured +/// ids (empty for `hir.init`) — see [`classify_for_chunking`]. +fn analyze_stmts_outlining( + stmts: &[perry_hir::Stmt], + has_top_level_await: bool, + target: usize, + must_stay_ids: &HashSet, +) -> EntryOutlineAnalysis { let total_stmts = stmts.len(); let ranges = chunk_ranges(total_stmts, target); - let chunk_count = count_prospective_chunks(stmts, target); + let chunk_count = count_prospective_chunks(stmts, target, must_stay_ids); // A top-level await splits init across an async suspension. A module-level // TDZ preallocation needs checked global loads, which module globals do not // provide yet. Both cases stay on the original lowering rather than // accepting a semantic approximation. - let gated_out = if hir.has_top_level_await { + let gated_out = if has_top_level_await { Some("top-level await") } else if stmts .iter() @@ -350,7 +657,8 @@ pub(crate) fn report_entry_outlining(hir: &HirModule) { // The transform runs in the HIR pipeline before codegen. Report clearly // when these figures describe the compact call stream rather than source // top-level statements. - let transform = if hir.functions.iter().any(is_entry_chunk) { + let already_outlined = hir.functions.iter().any(is_entry_chunk); + let transform = if already_outlined { " (already outlined; figures describe the chunk-call stream)" } else { "" @@ -371,6 +679,33 @@ pub(crate) fn report_entry_outlining(hir: &HirModule) { transform ), } + // #10575: a CommonJS module's real body is not in `hir.init` at all — it + // is the `__perry_cjs_factory` closure `cjs_wrap::wrap_commonjs_for_target` + // generates. Report on it too, using the same analysis, so the report + // reflects the body that will actually be outlined for a CJS module. + if let Some(perry_hir::Expr::Closure { body, captures, .. }) = + find_cjs_factory_closure(&hir.init) + { + let target = target_chunk_stmts(); + let must_stay_ids: HashSet = captures.iter().copied().collect(); + let fa = analyze_stmts_outlining(body, false, target, &must_stay_ids); + match fa.gated_out { + Some(reason) => eprintln!( + "[perry] entry-outline: {}: cjs factory: {} stmts; NOT a candidate ({}){}", + hir.name, fa.total_stmts, reason, transform + ), + None => eprintln!( + "[perry] entry-outline: {}: cjs factory: {} stmts → {} chunk(s) of ~{}, {} cross-chunk let(s) to globalize; candidate={}{}", + hir.name, + fa.total_stmts, + fa.chunk_count, + target, + fa.cross_chunk_lets, + fa.is_candidate(), + transform + ), + } + } } /// Outcome of attempting to outline a module entry body. @@ -568,11 +903,57 @@ fn stmt_contains_return(stmt: &perry_hir::Stmt) -> bool { } } +/// Does `stmt` reference any id in `must_stay_ids`? Always `false` (and O(1)) +/// for the overwhelmingly common empty case — `hir.init` never has must-stay +/// ids; only an outlined CJS factory closure does, one for each id it +/// captures from its enclosing scope (see [`is_cjs_factory_shape`]). +fn stmt_references_any(stmt: &perry_hir::Stmt, must_stay_ids: &HashSet) -> bool { + if must_stay_ids.is_empty() { + return false; + } + let mut refs = HashSet::new(); + collect_ref_ids_in_stmts(std::slice::from_ref(stmt), &mut refs); + refs.iter().any(|id| must_stay_ids.contains(id)) +} + +/// Like [`classify_top_level`], but a statement referencing one of +/// `must_stay_ids` is always treated as must-stay (`None`), regardless of +/// what `classify_top_level` would otherwise say. +/// +/// This exists for #10575's CJS-factory path: the factory closure captures +/// its own name from its enclosing scope (the wrapper's outer IIFE) — a +/// load-bearing self-reference `perry-runtime`'s `module_require.rs` calls +/// through on a circular-require recovery path. Chunk functions are plain, +/// non-capturing `hir.functions` entries, so a captured id can only be read +/// correctly from wherever the ORIGINAL closure-capture read already was — +/// it must never be relocated into a chunk. Keeping that one statement (in +/// practice, the wrapper's `__cjs_module.__perry_cjs_factory = +/// __perry_cjs_factory;` preamble line) inline preserves the exact +/// per-invocation closure-capture semantics codegen already provides, +/// instead of promoting the captured id to a module global — which would +/// change its lifetime from "fresh per closure call" to "one program-wide +/// instance," silently breaking that recovery path if it ever re-invokes the +/// factory. `hir.init`'s own outlining always passes an empty set here, so +/// this is a no-op for every non-CJS module. +fn classify_for_chunking( + stmt: &perry_hir::Stmt, + must_stay_ids: &HashSet, +) -> Option { + if stmt_references_any(stmt, must_stay_ids) { + return None; + } + classify_top_level(stmt) +} + /// How many chunk functions the interleaving would emit for `stmts` at /// `target` — a run of relocatable statements becomes ceil(run/target) chunks, /// and a must-stay statement (an unclassifiable shape) ends the current run. /// Used as a pre-scan so eligibility is decided before any mutation. -fn count_prospective_chunks(stmts: &[perry_hir::Stmt], target: usize) -> usize { +fn count_prospective_chunks( + stmts: &[perry_hir::Stmt], + target: usize, + must_stay_ids: &HashSet, +) -> usize { let mut chunks = 0usize; let mut run = 0usize; let mut run_safepoints = 0usize; @@ -584,7 +965,7 @@ fn count_prospective_chunks(stmts: &[perry_hir::Stmt], target: usize) -> usize { } }; for stmt in stmts { - match classify_top_level(stmt) { + match classify_for_chunking(stmt, must_stay_ids) { Some(TopLevelKind::Relocatable) => { run += 1; run_safepoints = run_safepoints.saturating_add( @@ -601,23 +982,57 @@ fn count_prospective_chunks(stmts: &[perry_hir::Stmt], target: usize) -> usize { chunks } -/// Attempt to outline `hir`'s entry body (#8595). Fail-safe: returns -/// `Skipped(reason)` and leaves `hir` untouched unless the whole body is -/// provably safe to relocate; callers proceed with the ordinary single-function -/// entry lowering in that case. +/// Attempt to outline `hir`'s entry body (#8595), and — if that finds nothing +/// to do — the CJS factory body instead (#10575). Fail-safe: returns +/// `Skipped(reason)` and leaves `hir` untouched unless a body is provably +/// safe to relocate; callers proceed with the ordinary single-function +/// lowering in that case. +/// +/// A module is only ever outlined from one origin: `hir.init` for an +/// ordinary (ESM/script) module, or the CJS factory for a CommonJS-wrapped +/// one. `hir.init` is tried first because it is cheap to check (a CJS +/// module's own `hir.init` is a handful of wrapper statements, never a +/// candidate) and because it is the historical #8595 behavior. pub fn outline_entry_module(hir: &mut HirModule) -> OutlineOutcome { - let mode = outline_mode(); + outline_entry_module_core(hir, outline_mode(), target_chunk_stmts()) +} + +/// Env-free core of [`outline_entry_module`] — the testable seam for the +/// hir.init-vs-CJS-factory orchestration itself (mode and chunk target are +/// ordinary parameters here, not read from the process environment, so +/// tests can exercise both branches without touching global env-var state +/// shared across a parallel test run). +fn outline_entry_module_core( + hir: &mut HirModule, + mode: OutlineMode, + target: usize, +) -> OutlineOutcome { if mode == OutlineMode::Disabled { return OutlineOutcome::Skipped("PERRY_OUTLINE_ENTRY disabled"); } let safepoints = crate::collectors::count_safepoint_sites(&hir.init); - if mode == OutlineMode::Auto && !meets_automatic_size_threshold(hir.init.len(), safepoints) { - return OutlineOutcome::Skipped("below automatic outlining threshold"); + let init_outcome = if mode == OutlineMode::Auto + && !meets_automatic_size_threshold(hir.init.len(), safepoints) + { + OutlineOutcome::Skipped("below automatic outlining threshold") + } else { + outline_entry_module_with_target(hir, target) + }; + if matches!(init_outcome, OutlineOutcome::Outlined { .. }) { + return init_outcome; + } + // #10575: `hir.init` was not a candidate (the overwhelmingly common case + // for a CJS-wrapped unit, whose whole body lives in `__perry_cjs_factory` + // instead — see the module doc comment). Try that function's body with + // the identical transform before giving up. + match outline_cjs_factory_module(hir, mode, target) { + outlined @ OutlineOutcome::Outlined { .. } => outlined, + OutlineOutcome::Skipped(_) => init_outcome, } - outline_entry_module_with_target(hir, target_chunk_stmts()) } -/// Env-free core of [`outline_entry_module`] — the testable seam. +/// Env-free core of [`outline_entry_module`]'s `hir.init` path — the testable +/// seam. fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> OutlineOutcome { let analysis = analyze_entry_outlining_with_target(hir, target); if let Some(reason) = analysis.gated_out { @@ -627,8 +1042,11 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli return OutlineOutcome::Skipped("not a candidate (too small)"); } // Pre-scan: decide eligibility before mutating. Outlining is worthwhile - // only if the interleaving would emit more than one chunk. - let prospective_chunks = count_prospective_chunks(&hir.init, target); + // only if the interleaving would emit more than one chunk. `hir.init` + // has no must-stay ids of its own (that concept exists only for the CJS + // factory's captured self-reference, #10575). + let no_must_stay_ids = HashSet::new(); + let prospective_chunks = count_prospective_chunks(&hir.init, target, &no_must_stay_ids); if prospective_chunks <= 1 { return OutlineOutcome::Skipped("would not split into multiple chunks"); } @@ -645,8 +1063,123 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli let module_is_strict = hir.init_is_strict; let original = std::mem::take(&mut hir.init); - // The rewritten body: chunk calls interleaved with any statement that had - // to stay inline, in original execution order. + let (new_body, chunk_fns) = chunk_statements( + original, + target, + &module_name, + module_is_strict, + &no_must_stay_ids, + &mut next_id, + ); + let chunks = chunk_fns.len(); + hir.functions.extend(chunk_fns); + hir.init = new_body; + OutlineOutcome::Outlined { chunks } +} + +/// The CJS-factory half of [`outline_entry_module`] (#10575): apply the +/// identical chunking transform to the `__perry_cjs_factory` closure's body +/// instead of `hir.init`. +/// +/// Unlike an ordinary named function declaration, this closure is NOT a +/// `hir.functions` entry — it is lexically nested inside the wrapper's outer +/// anonymous IIFE, so lowering represents it the same way as any other +/// function EXPRESSION: a `Stmt::Let` (naming it `__perry_cjs_factory`) +/// whose `init` is an `Expr::Closure`, reachable only by walking `hir.init`'s +/// statement/expression tree (see [`find_cjs_factory_closure_mut`]). New +/// chunk functions are still ordinary `hir.functions` entries — nothing +/// about where a *chunk* lives changes — only the body being split is found +/// differently. +fn outline_cjs_factory_module( + hir: &mut HirModule, + mode: OutlineMode, + target: usize, +) -> OutlineOutcome { + // Whole-module reads that must happen BEFORE taking a mutable borrow of + // `hir.init` below: once `find_cjs_factory_closure_mut` hands back a + // `&mut Expr` borrowed from `hir.init`, only `hir.init`-disjoint fields + // (like `hir.functions`, appended after) remain independently + // borrowable — a helper taking `&mut HirModule` as a whole would make + // the borrow checker treat every field as borrowed for the reference's + // lifetime, since it can't see the field-level split through the call. + let max_id = max_func_id(hir); + let module_name = hir.name.clone(); + + let Some(closure) = find_cjs_factory_closure_mut(&mut hir.init) else { + return OutlineOutcome::Skipped("no CommonJS factory function"); + }; + let perry_hir::Expr::Closure { + body, + captures, + is_strict, + .. + } = closure + else { + unreachable!("find_cjs_factory_closure_mut only ever returns Expr::Closure"); + }; + // The factory's own captured ids (in practice, just its self-reference — + // see the module doc comment) must never be relocated into a chunk: a + // chunk is a plain, non-capturing function and cannot read them. + // `is_cjs_factory_shape` already proved each one resolves to a `Let` in + // the enclosing IIFE, so keeping their reference sites inline preserves + // the exact closure-capture read codegen already emits for them. + let must_stay_ids: HashSet = captures.iter().copied().collect(); + + let safepoints = crate::collectors::count_safepoint_sites(body); + if mode == OutlineMode::Auto && !meets_automatic_size_threshold(body.len(), safepoints) { + return OutlineOutcome::Skipped("cjs factory below automatic outlining threshold"); + } + let analysis = analyze_stmts_outlining(body, false, target, &must_stay_ids); + if let Some(reason) = analysis.gated_out { + return OutlineOutcome::Skipped(reason); + } + if !analysis.is_candidate() { + return OutlineOutcome::Skipped("not a candidate (too small)"); + } + let prospective_chunks = count_prospective_chunks(body, target, &must_stay_ids); + if prospective_chunks <= 1 { + return OutlineOutcome::Skipped("would not split into multiple chunks"); + } + if prospective_chunks > (u32::MAX - max_id) as usize { + return OutlineOutcome::Skipped("function id space exhausted"); + } + let mut next_id = max_id + 1; + // A chunk carries the factory's own strictness, exactly as an `hir.init` + // chunk carries the module's (#9423) — these statements were the + // factory's top-level body a moment ago. + let is_strict = *is_strict; + let original = std::mem::take(body); + + let (new_body, chunk_fns) = chunk_statements( + original, + target, + &module_name, + is_strict, + &must_stay_ids, + &mut next_id, + ); + *body = new_body; + let chunks = chunk_fns.len(); + // `closure`/`body` borrowed only `hir.init`, so `hir.functions` remains + // independently borrowable here — see the comment above. + hir.functions.extend(chunk_fns); + OutlineOutcome::Outlined { chunks } +} + +/// Split `original` into chunk functions of ~`target` relocatable statements +/// each, returning the rewritten residual body (chunk calls interleaved with +/// any must-stay statement, in original order) and the new chunk functions. +/// Shared by the `hir.init` path and the CJS-factory path (#10575) — the only +/// difference between them is WHERE `original` came from and where the +/// results get written back. +fn chunk_statements( + original: Vec, + target: usize, + module_name: &str, + is_strict: bool, + must_stay_ids: &HashSet, + next_id: &mut u32, +) -> (Vec, Vec) { let mut new_body: Vec = Vec::new(); let mut chunk_fns: Vec = Vec::new(); // The current run of relocatable statements accumulating into a chunk. @@ -654,15 +1187,15 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli let mut run_safepoints = 0usize; // Emit the accumulated run as a chunk function and append its call, unless - // empty. `flush` is a closure over the mutable state via explicit params to - // keep the borrow checker happy. + // empty. `flush` is a plain fn over explicit params to keep the borrow + // checker happy. fn flush( run: &mut Vec, chunk_fns: &mut Vec, new_body: &mut Vec, next_id: &mut u32, module_name: &str, - module_is_strict: bool, + is_strict: bool, ) { if run.is_empty() { return; @@ -683,7 +1216,7 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli // real strictness. A chunk holds statements that were module // top-level code a moment ago; relocating them into a function must // not relax the mode they execute in. - is_strict: module_is_strict, + is_strict, is_exported: false, captures: Vec::new(), decorators: Vec::new(), @@ -699,20 +1232,20 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli } for stmt in original { - match classify_top_level(&stmt) { + match classify_for_chunking(&stmt, must_stay_ids) { Some(TopLevelKind::Relocatable) => run.push(stmt), None => { // A statement we cannot safely relocate (control flow, etc.): // end the current chunk run and keep this statement inline, at - // its original position, so eval order and any `hir.init` scan - // that reads it are preserved. + // its original position, so eval order and any residual-body + // scan that reads it are preserved. flush( &mut run, &mut chunk_fns, &mut new_body, - &mut next_id, - &module_name, - module_is_strict, + next_id, + module_name, + is_strict, ); run_safepoints = 0; new_body.push(stmt); @@ -728,9 +1261,9 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli &mut run, &mut chunk_fns, &mut new_body, - &mut next_id, - &module_name, - module_is_strict, + next_id, + module_name, + is_strict, ); run_safepoints = 0; } @@ -739,15 +1272,12 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli &mut run, &mut chunk_fns, &mut new_body, - &mut next_id, - &module_name, - module_is_strict, + next_id, + module_name, + is_strict, ); - let chunks = chunk_fns.len(); - hir.functions.extend(chunk_fns); - hir.init = new_body; - OutlineOutcome::Outlined { chunks } + (new_body, chunk_fns) } #[cfg(test)] @@ -809,7 +1339,7 @@ mod tests { }; let mut m = module_with_init(vec![allocation_heavy_stmt(), allocation_heavy_stmt()]); assert_eq!( - count_prospective_chunks(&m.init, usize::MAX), + count_prospective_chunks(&m.init, usize::MAX, &HashSet::new()), 2, "each allocation-heavy statement should exhaust a chunk budget" ); @@ -1150,4 +1680,289 @@ mod tests { let outcome = outline_entry_module_with_target(&mut m, 1); assert_eq!(outcome, OutlineOutcome::Outlined { chunks: 2 }); } + + // --- #10575: CJS factory outlining ------------------------------------- + + /// A plain closure expression, reused for both the wrapper's outer + /// anonymous IIFE and (by default) its inner `__perry_cjs_factory`. + fn factory_closure(func_id: u32, body: Vec) -> Expr { + Expr::Closure { + func_id, + params: vec![], + return_type: Type::Any, + body, + captures: vec![], + mutable_captures: vec![], + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: false, + is_async: false, + is_generator: false, + is_strict: false, + } + } + + fn call_no_args(callee: Expr) -> Expr { + Expr::Call { + callee: Box::new(callee), + args: vec![], + type_args: vec![], + byte_offset: 0, + } + } + + /// Build the exact `hir.init` shape `wrap_commonjs_for_target` produces + /// for a CJS module — `const _cjs = (function() { let + /// __perry_cjs_factory = function() { ... }; return + /// __perry_cjs_factory(); })();` — with an explicit `factory` closure + /// expression, so the shape-rejection tests can hand in a deliberately + /// wrong one. `leading` is prepended to the IIFE's own body (used to + /// prove a preceding `PreallocateBoxes`, as a real compile emits, does + /// not defeat the search). + fn cjs_wrapped_init_with(leading: Vec, factory: Expr) -> Vec { + let mut outer_body = leading; + outer_body.push(let_stmt(101, CJS_FACTORY_NAME, factory)); + outer_body.push(Stmt::Return(Some(call_no_args(Expr::LocalGet(101))))); + let outer_closure = factory_closure(100, outer_body); + vec![let_stmt(102, "_cjs", call_no_args(outer_closure))] + } + + fn cjs_wrapped_init(factory_body: Vec) -> Vec { + cjs_wrapped_init_with(vec![], factory_closure(103, factory_body)) + } + + fn factory_closure_with_captures(func_id: u32, body: Vec, captures: Vec) -> Expr { + let mut closure = factory_closure(func_id, body); + if let Expr::Closure { captures: c, .. } = &mut closure { + *c = captures; + } + closure + } + + #[test] + fn find_cjs_factory_closure_matches_only_the_wrap_generated_shape() { + assert!(find_cjs_factory_closure(&cjs_wrapped_init(vec![])).is_some()); + + // A preceding `PreallocateBoxes` (as a real compile emits for the + // hoisted function declaration) does not defeat the search. + let with_prealloc = cjs_wrapped_init_with( + vec![Stmt::PreallocateBoxes(vec![101])], + factory_closure(103, vec![]), + ); + assert!(find_cjs_factory_closure(&with_prealloc).is_some()); + + // A param disqualifies it — the wrapper's factory always takes none. + let mut with_param = factory_closure(103, vec![]); + if let Expr::Closure { params, .. } = &mut with_param { + params.push(perry_hir::Param { + id: 200, + name: "x".into(), + ty: Type::Any, + default: None, + decorators: vec![], + is_rest: false, + arguments_object: None, + }); + } + assert!(find_cjs_factory_closure(&cjs_wrapped_init_with(vec![], with_param)).is_none()); + + // Async/generator/capturing disqualify it too. + let mut async_factory = factory_closure(103, vec![]); + if let Expr::Closure { is_async, .. } = &mut async_factory { + *is_async = true; + } + assert!(find_cjs_factory_closure(&cjs_wrapped_init_with(vec![], async_factory)).is_none()); + + let mut generator_factory = factory_closure(103, vec![]); + if let Expr::Closure { is_generator, .. } = &mut generator_factory { + *is_generator = true; + } + assert!( + find_cjs_factory_closure(&cjs_wrapped_init_with(vec![], generator_factory)).is_none() + ); + + let mut capturing_factory = factory_closure(103, vec![]); + if let Expr::Closure { captures, .. } = &mut capturing_factory { + captures.push(7); + } + assert!( + find_cjs_factory_closure(&cjs_wrapped_init_with(vec![], capturing_factory)).is_none() + ); + + // A differently-named binding is never mistaken for the factory. + let mut unrelated_body = vec![let_stmt(101, "helper", factory_closure(103, vec![]))]; + unrelated_body.push(Stmt::Return(Some(call_no_args(Expr::LocalGet(101))))); + let unrelated = vec![let_stmt( + 102, + "_cjs", + call_no_args(factory_closure(100, unrelated_body)), + )]; + assert!(find_cjs_factory_closure(&unrelated).is_none()); + + // No CJS wrapper at all (an ordinary ESM module). + assert!(find_cjs_factory_closure(&[let_stmt(0, "x", Expr::Number(1.0))]).is_none()); + } + + #[test] + fn cjs_factory_body_is_outlined_when_hir_init_is_not_a_candidate() { + // hir.init is the wrap_commonjs shape: a single statement (the `_cjs` + // binding), never a candidate on its own (chunk_count is always 1 + // for one statement, regardless of target) — the real body lives + // inside the nested `__perry_cjs_factory` closure. + // + // Factory body: `let shared = 1` (chunk), a lone statement (chunk), + // then `shared` read back (chunk) — the same cross-chunk-let shape as + // `only_boundary_crossing_or_preallocated_bindings_become_globals`, + // now living inside a closure instead of directly in `hir.init`. + let mut m = module_with_init(cjs_wrapped_init(vec![ + let_stmt(10, "shared", Expr::Number(1.0)), + Stmt::Expr(Expr::Number(0.0)), + Stmt::Expr(Expr::LocalGet(10)), + ])); + + let outcome = outline_entry_module_core(&mut m, OutlineMode::Forced, 1); + assert_eq!(outcome, OutlineOutcome::Outlined { chunks: 3 }); + + // hir.init's own top-level shape is untouched — one statement, the + // `_cjs` binding — the transform outlined the FACTORY, not the + // module top level. + assert_eq!(m.init.len(), 1); + assert!(matches!(&m.init[0], Stmt::Let { name, .. } if name == "_cjs")); + + let Some(Expr::Closure { body, .. }) = find_cjs_factory_closure(&m.init) else { + panic!("factory closure still present and findable"); + }; + assert_eq!(body.len(), 3, "three ordered chunk calls"); + assert!( + body.iter() + .all(|s| matches!(s, Stmt::Expr(Expr::Call { .. }))), + "every residual statement in the factory is a chunk call: {body:?}" + ); + assert_eq!( + m.functions.iter().filter(|f| is_entry_chunk(f)).count(), + 3, + "three chunk functions were created" + ); + + // The cross-chunk let inside the factory is promoted exactly like a + // cross-chunk `hir.init` let would be. + assert_eq!(outlined_entry_global_let_ids(&m), HashSet::from([10])); + + // The factory's original statement order is recoverable for any + // codegen scan that needs it (mirrors `logical_entry_stmts` for + // `hir.init`). + let logical = logical_outlined_function_stmts(&m); + assert_eq!(logical.len(), 3); + assert!(matches!(logical[0], Stmt::Let { id: 10, .. })); + } + + #[test] + fn cjs_factory_self_reference_capture_stays_inline_not_chunked() { + // Mirrors the real `wrap_commonjs_for_target` shape: the wrapper's + // preamble does `__cjs_module.__perry_cjs_factory = + // __perry_cjs_factory;`, so the factory closure ALWAYS captures its + // own name (id 101 here — the wrapper's own `Let`) from the + // enclosing IIFE. `perry-runtime`'s `module_require.rs` calls + // through that captured value on a circular-require recovery path, + // so it must keep working after outlining. A chunk is a plain, + // non-capturing `hir.functions` entry and cannot read a captured id + // — the statement reading it (here, a bare `LocalGet(101)` standing + // in for the real assignment) must stay in the factory's own + // residual body, never relocated into a chunk, so it keeps reading + // it via ordinary closure-capture codegen exactly as before + // outlining (#10575) — NOT via a promoted module global, which + // would change a per-invocation-fresh capture into a program-wide + // single instance and silently break that recovery path if it ever + // re-invokes the factory. + let self_reference_read = Stmt::Expr(Expr::LocalGet(101)); + let factory = factory_closure_with_captures( + 103, + vec![ + self_reference_read, + let_stmt(10, "shared", Expr::Number(1.0)), + Stmt::Expr(Expr::Number(0.0)), + Stmt::Expr(Expr::LocalGet(10)), + ], + vec![101], + ); + let mut m = module_with_init(cjs_wrapped_init_with(vec![], factory)); + + let outcome = outline_entry_module_core(&mut m, OutlineMode::Forced, 1); + assert_eq!(outcome, OutlineOutcome::Outlined { chunks: 3 }); + + let Some(Expr::Closure { body, .. }) = find_cjs_factory_closure(&m.init) else { + panic!("factory closure still present and findable"); + }; + assert!( + matches!(&body[0], Stmt::Expr(Expr::LocalGet(101))), + "the captured self-reference read stayed inline, in its \ + original (first) position: {body:?}" + ); + assert!( + body[1..] + .iter() + .all(|s| matches!(s, Stmt::Expr(Expr::Call { .. }))), + "everything else still outlined into ordered chunk calls: {body:?}" + ); + } + + #[test] + fn outline_entry_module_core_prefers_hir_init_over_the_cjs_factory() { + // hir.init has three top-level statements — two ordinary ones and + // (as one item among them) the CJS wrapper statement — all + // independently large enough to outline at target=1. hir.init must + // win: #8595's original behavior is unchanged, and a module is only + // ever outlined from one origin. #8595's transform has no CJS-aware + // special case, so it relocates the wrapper statement whole (as an + // opaque `Stmt::Let`) into its own chunk, untouched internally. + let mut init = vec![ + let_stmt(0, "x", Expr::Number(1.0)), + Stmt::Expr(Expr::LocalGet(0)), + ]; + init.extend(cjs_wrapped_init(vec![ + let_stmt(10, "shared", Expr::Number(1.0)), + Stmt::Expr(Expr::LocalGet(10)), + ])); + let mut m = module_with_init(init); + + let outcome = outline_entry_module_core(&mut m, OutlineMode::Forced, 1); + assert_eq!(outcome, OutlineOutcome::Outlined { chunks: 3 }); + assert_eq!(m.init.len(), 3, "hir.init's own three chunk calls"); + + // The factory body is untouched: reconstruct the logical hir.init + // view (inlining hir.init's own chunks back) and dig into the + // relocated CJS-wrapper statement's nested closure. + let logical = logical_entry_stmts(&m); + assert_eq!(logical.len(), 3); + let cjs_stmt: &Stmt = *logical + .iter() + .find(|s| matches!(s, Stmt::Let { name, .. } if name == "_cjs")) + .expect("the CJS wrapper statement survived, just relocated"); + let Some(Expr::Closure { body, .. }) = + find_cjs_factory_closure(std::slice::from_ref(cjs_stmt)) + else { + panic!("the factory closure is still findable inside it"); + }; + assert_eq!(body.len(), 2, "the factory body was never touched"); + assert!(matches!(&body[0], Stmt::Let { id: 10, .. })); + + // No factory-outlining occurred: only hir.init's own three chunks + // exist. + assert_eq!(m.functions.iter().filter(|f| is_entry_chunk(f)).count(), 3); + } + + #[test] + fn outline_entry_module_core_declines_with_no_candidate_on_either_side() { + let mut m = module_with_init(vec![let_stmt(0, "_cjs", Expr::Number(0.0))]); + // No `__perry_cjs_factory` function at all (an ordinary small ESM + // module) — nothing to outline on either side. + let outcome = outline_entry_module_core(&mut m, OutlineMode::Forced, 1); + assert_eq!( + outcome, + OutlineOutcome::Skipped("not a candidate (too small)") + ); + assert_eq!(m.init.len(), 1); + assert!(m.functions.is_empty()); + } } diff --git a/crates/perry-codegen/src/codegen/module_globals_emit.rs b/crates/perry-codegen/src/codegen/module_globals_emit.rs index ee0669c9a4..c13c43a445 100644 --- a/crates/perry-codegen/src/codegen/module_globals_emit.rs +++ b/crates/perry-codegen/src/codegen/module_globals_emit.rs @@ -381,9 +381,16 @@ pub(crate) fn emit_module_globals( } } let logical_entry = super::entry_outline::logical_entry_stmts(hir); + // #10575: a CommonJS module's real top level is the `__perry_cjs_factory` + // function body, not `hir.init`. When that body was outlined (see + // `entry_outline::outline_cjs_factory_module`), its cross-chunk `var`s + // need exactly the same global promotion `hir.init`'s cross-chunk `let`s + // get. Empty when nothing was outlined from a function body, so this is a + // no-op for every module that isn't a large CJS bundle. + let logical_outlined_functions = super::entry_outline::logical_outlined_function_stmts(hir); let outlined_entry_globals = super::entry_outline::outlined_entry_global_let_ids(hir); let mut init_lets: Vec<&perry_hir::Stmt> = Vec::new(); - for stmt in logical_entry { + for stmt in logical_entry.into_iter().chain(logical_outlined_functions) { collect_init_lets(std::slice::from_ref(stmt), &mut init_lets); } // `Expr::New { class_name }` does not retain whether an unqualified name