diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index d8cc41a48..6429cb0da 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -74,7 +74,14 @@ pub(super) struct ModuleArtifactsCtx<'a> { pub func_signatures: &'a HashMap, pub func_synthetic_arguments: &'a std::collections::HashSet, pub module_boxed_vars: &'a std::collections::HashSet, + /// Typed-ABI capture-representation oracle: module-wide `Stmt::Let` types + /// MINUS boxed ids (#5869). Only the typed closure clones read this. pub module_local_types: &'a HashMap, + /// #6369: receiver-type oracle for closure bodies — the same module-wide + /// `Stmt::Let` types with no representation filtering, mirroring the + /// `module_global_types` seed that `compile_function` / `compile_method` + /// already use. Feeds `FnCtx.local_types` only. + pub module_receiver_types: &'a HashMap, pub closure_rest_params: &'a HashMap, pub closure_synthetic_arguments: &'a std::collections::HashSet, pub closure_rest_and_arguments: &'a std::collections::HashSet, @@ -194,6 +201,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { func_synthetic_arguments, module_boxed_vars, module_local_types, + module_receiver_types, closure_rest_params, closure_synthetic_arguments, closure_rest_and_arguments, @@ -277,7 +285,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { func_synthetic_arguments, module_prefix, module_boxed_vars, - module_local_types, + module_receiver_types, closure_rest_params, cross_module, ) diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index beb142dc9..8ffdb8c82 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -498,7 +498,11 @@ pub(super) fn compile_closure( func_synthetic_arguments: &std::collections::HashSet, module_prefix: &str, module_boxed_vars: &std::collections::HashSet, - module_local_types: &HashMap, + // #6369: receiver-type oracle (module-wide `Stmt::Let` types, unfiltered). + // Seeds `FnCtx.local_types` so a binding captured from an enclosing scope + // keeps its declared type at its read sites. NOT the typed-ABI capture + // map — the typed closure clones take `module_local_types` instead. + module_receiver_types: &HashMap, closure_rest_params: &HashMap, cross_module: &CrossModuleCtx, ) -> Result<()> { @@ -618,7 +622,7 @@ pub(super) fn compile_closure( // typed fast path and return undefined. let mut local_types: HashMap = params.iter().map(|p| (p.id, p.ty.clone())).collect(); - for (id, ty) in module_local_types.iter() { + for (id, ty) in module_receiver_types.iter() { local_types.entry(*id).or_insert_with(|| ty.clone()); } @@ -736,6 +740,8 @@ pub(super) fn compile_closure( &cross_module.clamp3_functions, &closure_boxed_vars, module_globals, + // #6369: declared types of module-scope bindings this closure captures. + &local_types, classes, &cross_module.compile_time_constants, &cross_module.module_dispatch, diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 9bad47b3e..f477e96d3 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -567,6 +567,9 @@ pub(super) fn compile_module_entry( &cross_module.clamp3_functions, &main_boxed_vars, module_globals, + // Module scope IS this body: its `Stmt::Let`s are walked directly, so + // there is nothing to seed from an enclosing scope (#6369). + &HashMap::new(), classes, &cross_module.compile_time_constants, &cross_module.module_dispatch, @@ -1127,6 +1130,8 @@ pub(super) fn compile_module_entry( &cross_module.clamp3_functions, &init_boxed_vars, module_globals, + // Module scope IS this body — see the `main` fact graph above (#6369). + &HashMap::new(), classes, &cross_module.compile_time_constants, &cross_module.module_dispatch, diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 085148ace..95a083f10 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -454,6 +454,8 @@ pub(super) fn compile_function( &cross_module.clamp3_functions, &boxed_vars, module_globals, + // #6369: declared types of module-scope bindings this body reads through. + &local_types, classes, &cross_module.compile_time_constants, &cross_module.module_dispatch, diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 01501ba7f..00131e0d0 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -369,6 +369,8 @@ pub(super) fn compile_method( &cross_module.clamp3_functions, &method_boxed_vars, module_globals, + // #6369: declared types of module-scope bindings this body reads through. + &local_types, classes, &cross_module.compile_time_constants, &cross_module.module_dispatch, @@ -1302,6 +1304,8 @@ pub(super) fn compile_static_method( &cross_module.clamp3_functions, &static_boxed_vars, module_globals, + // #6369: declared types of module-scope bindings this body reads through. + &local_types, classes, &cross_module.compile_time_constants, &cross_module.module_dispatch, diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 058a65312..4ff2ecae6 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -1758,7 +1758,24 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // Module-wide boxed-var union + LocalId→Type map. See `boxed_locals`. let module_boxed_vars = boxed_locals::collect_module_boxed_vars(hir); - let mut module_local_types = boxed_locals::collect_module_local_types(hir); + // #6369: the *receiver-type oracle* for closure bodies — every module-wide + // `Stmt::Let` type, with NO representation-driven filtering. `FnCtx. + // local_types` is what `static_type_of` / `is_array_expr` / + // `receiver_class_name` read to pick a specialized (guarded) access path, + // and a binding's declared type is a fact about its VALUE — it holds no + // matter whether the slot backing it is a plain alloca, a box cell, or a + // module global (every read routes through the matching load, and every + // specialized path is a runtime-guarded fast path with the generic + // fallback intact). `compile_function` / `compile_method` already seed + // `local_types` from the unfiltered `module_global_types`; closures are + // the outlier, and the two filters below (both aimed squarely at the + // typed-ABI *capture representation*) were silently dropping the type of + // every captured binding from the closure oracle too — so a captured + // `number[]` reached `arr[i]` as an unknown receiver and fell all the way + // to `js_dyn_index_get` (27× slower than the same array passed as a + // parameter, and no faster than an untyped array). + let module_receiver_types = boxed_locals::collect_module_local_types(hir); + let mut module_local_types = module_receiver_types.clone(); // #5869 residual: a BOXED local's slot holds a BOX POINTER, never the // typed value — advertising its declared type to the typed-ABI layer // made the typed closure specializations (typed_f64/i1/i32/string @@ -1772,6 +1789,10 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // for closures inside labeled blocks.) Removing boxed ids here // disqualifies every type-directed unboxed access on a boxed slot in // one place; consumers fall back to the generic (box-aware) paths. + // + // #6369: scoped to the typed-ABI copy (like the module-globals filter + // below). The hazard is the unboxed *capture representation*, not the + // receiver type — see `module_receiver_types` above. module_local_types.retain(|id, _| !module_boxed_vars.contains(id)); // #5982 (#5466 regression): a MODULE-GLOBAL captured local is read by a // closure through `@perry_global_*`, NOT the closure's capture array — @@ -1796,8 +1817,8 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // `assert.throws(TypeError, () => arr.every())` then saw no exception // (24 regressions: Array HOF + symbol-strict [[Set]], all via harness // closures). Only the typed-ABI *specialization* decision needs the - // module-globals removed, so scope the filter to a dedicated copy and - // leave `module_local_types` (the receiver oracle) module-global-inclusive. + // module-globals removed, so scope the filter to a dedicated copy — the + // receiver oracle is `module_receiver_types` (#6369). let typed_abi_local_types: std::collections::HashMap = module_local_types .iter() @@ -2258,6 +2279,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> func_synthetic_arguments: &func_synthetic_arguments, module_boxed_vars: &module_boxed_vars, module_local_types: &module_local_types, + module_receiver_types: &module_receiver_types, closure_rest_params: &closure_rest_params, closure_synthetic_arguments: &closure_synthetic_arguments, closure_rest_and_arguments: &closure_rest_and_arguments, diff --git a/crates/perry-codegen/src/collectors/hir_facts.rs b/crates/perry-codegen/src/collectors/hir_facts.rs index 487713c22..428076e41 100644 --- a/crates/perry-codegen/src/collectors/hir_facts.rs +++ b/crates/perry-codegen/src/collectors/hir_facts.rs @@ -304,6 +304,7 @@ pub(crate) fn collect_type_facts( arg_dependent_clamp_fn_ids: &HashSet, boxed_vars: &HashSet, module_globals: &HashMap, + binding_types: &HashMap, classes: &HashMap, compile_time_constants: &HashMap, module_dispatch: &super::ModuleDispatchFacts, @@ -315,7 +316,8 @@ pub(crate) fn collect_type_facts( arg_dependent_clamp_fn_ids, ); let unsigned_i32_locals = super::i32_locals::collect_unsigned_i32_locals(stmts); - let (array_facts, effect_facts, materialization_hazards) = collect_array_facts(stmts, params); + let (array_facts, effect_facts, materialization_hazards) = + collect_array_facts(stmts, params, module_globals, binding_types); let index_used_locals = super::index_uses::collect_index_used_locals(stmts); let strictly_i32_bounded_locals = super::i32_locals::collect_strictly_i32_bounded_locals( stmts, @@ -419,6 +421,7 @@ pub(crate) fn collect_native_region_fact_graph( arg_dependent_clamp_fn_ids: &HashSet, boxed_vars: &HashSet, module_globals: &HashMap, + binding_types: &HashMap, classes: &HashMap, compile_time_constants: &HashMap, module_dispatch: &super::ModuleDispatchFacts, @@ -431,6 +434,7 @@ pub(crate) fn collect_native_region_fact_graph( arg_dependent_clamp_fn_ids, boxed_vars, module_globals, + binding_types, classes, compile_time_constants, module_dispatch, @@ -455,6 +459,7 @@ pub(crate) fn collect_hir_facts( &HashMap::new(), &HashMap::new(), &HashMap::new(), + &HashMap::new(), // No class table here, so no scalar-method summary can apply; the // conservative default keeps it that way if one ever could. &super::ModuleDispatchFacts::default(), @@ -602,9 +607,12 @@ fn is_fresh_uint8array_length_literal(expr: &Expr) -> bool { fn collect_array_facts( stmts: &[Stmt], params: &[perry_hir::Param], + module_globals: &HashMap, + binding_types: &HashMap, ) -> (ArrayFacts, EffectFacts, MaterializationHazardFacts) { let mut collector = ArrayFactCollector::default(); collector.seed_params(params); + collector.seed_module_bindings(module_globals, binding_types); collector.collect_stmts(stmts); collector.finish() } @@ -616,11 +624,14 @@ struct ArrayFactCollector { aliased_locals: HashSet, length_mutation_locals: HashSet, materialization_hazard_locals: HashSet, - /// #6011: param ids seeded purely from a declared `Packed*` array type. - /// A param can receive ANY array at runtime, so these seeds are only - /// versioning hints for the runtime-guard-validated packed loop matcher; - /// body-observed mutations still downgrade them like any tracked local. - param_seeded_locals: HashSet, + /// #6011/#6369: ids seeded purely from a *declared* `Packed*` array type — + /// function params, and module-scope bindings read from an enclosing scope. + /// Neither can be proven packed from this body alone (a param receives any + /// array at runtime; a module global can be rewritten by another function), + /// so these seeds are only versioning hints for the runtime-guard-validated + /// packed loop matcher; body-observed mutations still downgrade them like + /// any tracked local. + declared_type_seeded_locals: HashSet, unknown_call_escape: bool, async_microtask_escape: bool, } @@ -638,17 +649,51 @@ impl ArrayFactCollector { if param.is_rest { continue; } - let kind = array_kind_from_declared_type(¶m.ty); - if matches!( - kind, - ArrayKindFact::PackedI32 | ArrayKindFact::PackedU32 | ArrayKindFact::PackedF64 - ) { - self.local_kinds.insert(param.id, kind); - self.param_seeded_locals.insert(param.id); + self.seed_declared_array_type(param.id, ¶m.ty); + } + } + + /// #6369: same declared-type seed for a MODULE-SCOPE binding this body only + /// *reads through* — `const prices: number[] = […]` at module scope, used + /// inside a function or closure. Without it the binding has no array fact at + /// all in this body, so the packed-numeric loop matchers reject it and a + /// captured `number[]` is stuck one tier below the identical array passed as + /// a parameter (which #6011 already seeds this exact way). + /// + /// Soundness is the same as the param seed's, and rests on the same two + /// pillars: (1) the loop's entry guard + /// (`js_typed_feedback_packed_*_loop_guard`) re-validates the *actual* + /// runtime array — kind, packed-ness and the whole index window — and side + /// exits to the generic loop when it does not hold, so a wrong seed costs a + /// guard, never correctness; (2) the matched loop body is walked and admits + /// only pure element reads/writes and scalar updates — no call, no `await`, + /// no closure — so nothing reachable from the body can rebind the global or + /// change the array's shape between the guard and the last iteration. Any + /// mutation the body walk *does* see (push, alias, `length` write, identity + /// escape) downgrades the seed exactly like a `Stmt::Let`-declared array. + fn seed_module_bindings( + &mut self, + module_globals: &HashMap, + binding_types: &HashMap, + ) { + for id in module_globals.keys() { + if let Some(ty) = binding_types.get(id) { + self.seed_declared_array_type(*id, ty); } } } + fn seed_declared_array_type(&mut self, id: u32, ty: &perry_types::Type) { + let kind = array_kind_from_declared_type(ty); + if matches!( + kind, + ArrayKindFact::PackedI32 | ArrayKindFact::PackedU32 | ArrayKindFact::PackedF64 + ) { + self.local_kinds.insert(id, kind); + self.declared_type_seeded_locals.insert(id); + } + } + fn collect_stmts(&mut self, stmts: &[Stmt]) { for stmt in stmts { self.collect_stmt(stmt); @@ -1179,15 +1224,16 @@ impl ArrayFactCollector { self.unknown_call_escape = true; let ids: Vec = self.local_kinds.keys().copied().collect(); for id in ids { - // #6011: param-seeded facts still lose their packed kind on an - // unknown call (conservative for every fact consumer), but do NOT - // gain a materialization hazard — params were never hazard-tracked - // before seeding existed, and hazards feed non-fact consumers + // #6011: declared-type-seeded facts still lose their packed kind on + // an unknown call (conservative for every fact consumer), but do NOT + // gain a materialization hazard — params (and, #6369, module-scope + // bindings) were never hazard-tracked before seeding existed, and + // hazards feed non-fact consumers // (`array_length_receiver_is_loop_local`'s length-hoist gate) that // must not regress for `i < param.length` loops in call-bearing // bodies. Explicit hazards (freeze/defineProperty/identity escape - // on the param itself) still mark normally. - if !self.param_seeded_locals.contains(&id) { + // on the binding itself) still mark normally. + if !self.declared_type_seeded_locals.contains(&id) { self.mark_array_materialization_hazard(id); } self.update_array_kind_for_local(id, ArrayKindFact::Unknown); @@ -1663,6 +1709,7 @@ mod tests { &HashSet::new(), &HashMap::new(), &HashMap::new(), + &HashMap::new(), &constants, &crate::collectors::ModuleDispatchFacts::default(), ); @@ -1754,6 +1801,7 @@ mod tests { &HashMap::new(), &HashMap::new(), &HashMap::new(), + &HashMap::new(), &crate::collectors::ModuleDispatchFacts::default(), ); diff --git a/crates/perry-codegen/src/expr/literals_vars.rs b/crates/perry-codegen/src/expr/literals_vars.rs index f7387c742..5d22a8ba0 100644 --- a/crates/perry-codegen/src/expr/literals_vars.rs +++ b/crates/perry-codegen/src/expr/literals_vars.rs @@ -442,7 +442,21 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } // Boxed local in enclosing function: load the slot (box // pointer), deref via js_box_get_bits. - if ctx.boxed_vars.contains(id) { + // + // #6369: never for a MODULE GLOBAL. Its storage is the + // `@perry_global_*` cell, which holds the VALUE — `Stmt::Let` + // stores it there directly and never allocates a box — so a + // box deref here would reinterpret e.g. an array pointer as a + // box pointer. `LocalSet` and `Update` (below) already carry + // this exclusion; the read path was the odd one out, and it + // only stayed latent because a module global normally has no + // `ctx.locals` slot to find. The packed-loop invariant-global + // read cache installs exactly such a slot (`loops.rs` aliases + // the global into `ctx.locals` for the duration of the loop), + // so any module global that landed in the module-wide boxed + // union — which every closure inherits wholesale — was read + // back as garbage (`NaN`) there. + if ctx.boxed_vars.contains(id) && !ctx.module_globals.contains_key(id) { if let Some(slot) = ctx.locals.get(id).cloned() { let blk = ctx.block(); let box_ptr = blk.load(I64, &slot); diff --git a/crates/perry-codegen/src/lower_call/early_branches.rs b/crates/perry-codegen/src/lower_call/early_branches.rs index 33aa14ce0..1c2de8bc7 100644 --- a/crates/perry-codegen/src/lower_call/early_branches.rs +++ b/crates/perry-codegen/src/lower_call/early_branches.rs @@ -203,9 +203,24 @@ pub fn try_lower_index_get_call( // expression — those have dedicated lowering and aren't method // dispatch. Class refs are the exception: `C[1]()` is a static // computed method call after ToPropertyKey canonicalizes `1` to "1". + // + // The receiver must actually be an array for that bail to be sound. A + // numeric key alone is not enough: `obj[k]()` on a *plain object* is + // still a method call and must bind `this = obj` (#6328). The element + // lowering reads the slot and calls it as a bare closure, dropping the + // receiver. That used to be unreachable here because in an `async` body + // the async-to-generator transform boxes body locals, so `k`'s numeric + // type was invisible and this guard never fired; #6369 restores those + // declared types, which made the plain-object case reachable and + // resurrected the `this === undefined` bug. Gate on the receiver being + // a real array so non-arrays fall through to the dispatch tower below, + // which binds `this` for both numeric and string keys. let object_is_class_ref = matches!(object.as_ref(), Expr::ClassRef(_)) || matches!(object.as_ref(), Expr::ExternFuncRef { name, .. } if ctx.class_ids.contains_key(name)); - if crate::type_analysis::is_numeric_expr(ctx, index) && !object_is_class_ref { + if crate::type_analysis::is_numeric_expr(ctx, index) + && crate::type_analysis::is_array_expr(ctx, object) + && !object_is_class_ref + { return Ok(None); } if crate::type_analysis::receiver_class_name(ctx, object).as_deref() == Some("Server") diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index cc9cfd6ed..069f07699 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -539,12 +539,7 @@ fn match_packed_f64_range_loop( } for access in accesses.values() { let arr_id = access.array_id; - if !ctx.locals.contains_key(&arr_id) - || ctx.boxed_vars.contains(&arr_id) - || ctx.module_globals.contains_key(&arr_id) - || ctx.scalar_replaced_arrays.contains_key(&arr_id) - || ctx.native_facts.has_materialization_hazard(arr_id) - { + if !packed_loop_array_binding_is_eligible(ctx, arr_id) { return None; } // The guard takes i32 window endpoints; make sure `start + offset` @@ -1684,12 +1679,7 @@ fn match_packed_f64_versioned_loop( { return None; } - if !ctx.locals.contains_key(&hoist.arr_id) - || ctx.boxed_vars.contains(&hoist.arr_id) - || ctx.module_globals.contains_key(&hoist.arr_id) - || ctx.scalar_replaced_arrays.contains_key(&hoist.arr_id) - || ctx.native_facts.has_materialization_hazard(hoist.arr_id) - { + if !packed_loop_array_binding_is_eligible(ctx, hoist.arr_id) { return None; } let store_array_kind = @@ -1748,6 +1738,50 @@ fn local_array_element_type<'t>( } } +/// #6369: which *bindings* a packed-numeric loop may version on. +/// +/// The lowered fast loop reads the array box out of the binding once per +/// iteration and then works on raw element slots, so the binding must be one +/// whose read is a plain load of the array value: +/// +/// - a stack local (`ctx.locals`) — the original case; or +/// - a module-scope global (`@perry_global_*`) — the shape a bundle is made of +/// (`const rows: number[] = […]` at module scope, read from a function or an +/// arrow closure). Its read is a `load double, ptr @perry_global_*`, and the +/// matched loop body admits no call / `await` / closure, so nothing can rebind +/// the global or reshape the array between the entry guard and the last +/// iteration. Before this, a captured array was rejected here and fell to the +/// per-element guarded path (or, with no declared type reaching the body at +/// all, to fully generic `js_dyn_index_get`) — 27× slower than the identical +/// array passed as a parameter. +/// +/// Still rejected: a BOXED stack slot (it holds a box pointer, not the array), a +/// closure-capture slot (its read is a `js_closure_get_capture_*` call, which the +/// raw-slot fast loop cannot host), a scalar-replaced array, and anything the +/// fact graph flagged with a materialization hazard. +/// +/// The storage test mirrors `Expr::LocalGet`'s own precedence (capture slot → +/// box slot → alloca → module global) exactly, which is what makes the +/// module-global arm safe from the boxed set: `compile_closure` seeds +/// `ctx.boxed_vars` with the module-wide boxed UNION, so a module global that is +/// boxed *in some other scope* shows up as boxed here — while its read in this +/// body is still a plain `@perry_global_*` load, because the box slot arm needs +/// an alloca (`ctx.locals`) this body does not have. Reading the flag without +/// that distinction is what kept a captured `const rows: number[]` off the fast +/// loop in a closure while the same code in a plain function got it. +fn packed_loop_array_binding_is_eligible(ctx: &FnCtx<'_>, arr_id: u32) -> bool { + let storage_is_addressable = if ctx.closure_captures.contains_key(&arr_id) { + false + } else if ctx.locals.contains_key(&arr_id) { + !ctx.boxed_vars.contains(&arr_id) + } else { + ctx.module_globals.contains_key(&arr_id) + }; + storage_is_addressable + && !ctx.scalar_replaced_arrays.contains_key(&arr_id) + && !ctx.native_facts.has_materialization_hazard(arr_id) +} + fn local_is_number_array(ctx: &FnCtx<'_>, local_id: u32) -> bool { matches!( local_array_element_type(ctx, local_id), diff --git a/crates/perry/tests/issue_6369_captured_array_specialization.rs b/crates/perry/tests/issue_6369_captured_array_specialization.rs new file mode 100644 index 000000000..bfb305b86 --- /dev/null +++ b/crates/perry/tests/issue_6369_captured_array_specialization.rs @@ -0,0 +1,198 @@ +//! Regression test for #6369: a numeric array reached through a *capture* — a +//! module-scope `const` read inside a function or an arrow closure — must get +//! the same guarded packed-numeric access path as the identical array passed as +//! a parameter. +//! +//! Before the fix the declared type never reached the capture's read sites, so +//! `rows[i]` in a closure lowered to the fully generic `js_dyn_index_get` (27× +//! slower than the parameter form, and no faster than an untyped array — the +//! `number[]` annotation bought exactly nothing once the value was captured). +//! This matters far beyond the microbenchmark: a bundle is overwhelmingly +//! module-scope `const`s captured by closures, so in practice almost nothing was +//! reaching the fast path. +//! +//! Two things are pinned here: +//! * `specialized_path` — the IR evidence: the captured form emits the +//! packed-numeric loop guard and *no* `js_dyn_index_get` call at all. +//! * `semantics_match_spec` — the behaviour, across every shape the guard's +//! fallback has to catch: heterogeneous elements, holes, out-of-bounds, +//! negative / fractional / non-canonical keys, a rebound array, a shrunk +//! array, and stores. Every expectation below is the output Node prints for +//! the same program. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +/// Captured-array program shared by both tests. `salt` is woven into a comment +/// so each run has a distinct content hash and cannot be served from perry's +/// object cache (a cache hit re-links without re-emitting IR). +fn source(salt: &str) -> String { + format!( + r#" +// cache-salt: {salt} +const rows: number[] = []; +for (let i = 0; i < 8; i++) rows.push(i * 2); +// The whole point of #6369: `rows` is CAPTURED here, not passed in. +const hot = (): number => {{ + let s = 0; + for (let r = 0; r < 3; r++) for (let i = 0; i < 8; i++) s += rows[i]; + return s; +}}; +console.log("A=" + hot()); + +// A captured array that is NOT numeric must still read exactly like Node. +const mixed: any[] = [1, "x", null]; +const readMixed = (): string => + String(mixed[0]) + "," + String(mixed[1]) + "," + String(mixed[2]) + "," + String(mixed[3]); +console.log("B=" + readMixed()); + +// Holes: a deleted element reads `undefined`, and so does everything past the end. +const holey: number[] = [1, 2, 3, 4]; +delete (holey as any)[2]; +const readHoley = (): string => {{ + let s = ""; + for (let i = 0; i < 6; i++) s += String(holey[i]) + ";"; + return s; +}}; +console.log("C=" + readHoley()); + +// Negative / fractional / non-canonical / out-of-bounds keys are ordinary +// [[Get]]s, never elements — only the canonical "1" hits element 1. +const small: number[] = [10, 20, 30]; +const exotic = (): string => + String(small[-1]) + "," + String((small as any)[1.5]) + "," + String((small as any)["1"]) + + "," + String((small as any)["01"]) + "," + String(small[99]); +console.log("D=" + exotic()); + +// The capture is a *binding*: rebinding it to a different array must be seen. +let rebind: number[] = [1, 2, 3]; +const sumRebind = (): number => {{ + let s = 0; + for (let i = 0; i < 3; i++) s += rebind[i]; + return s; +}}; +const d1 = sumRebind(); +rebind = [10, 20, 30]; +const d2 = sumRebind(); +console.log("E=" + d1 + "," + d2); + +// Shrinking the array under the guard: the reads past the new length are +// `undefined`, so the numeric accumulator becomes NaN (as in Node). +const shrink: number[] = [1, 2, 3, 4]; +const sumShrink = (): number => {{ + let s = 0; + for (let i = 0; i < 4; i++) s += shrink[i]; + return s; +}}; +const e1 = sumShrink(); +shrink.length = 2; +const e2 = sumShrink(); +console.log("F=" + e1 + "," + e2); + +// Element STORES through a captured array take the guarded store path. +const store: number[] = [0, 0, 0, 0]; +const doStore = (): void => {{ + for (let i = 0; i < 4; i++) store[i] = i * 3; +}}; +doStore(); +console.log("G=" + store.join(",")); +"# + ) +} + +fn compile(salt: &str, keep_ir: bool) -> (tempfile::TempDir, PathBuf, String) { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + std::fs::write(&entry, source(salt)).expect("write entry"); + + let mut cmd = Command::new(perry_bin()); + cmd.current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output); + if keep_ir { + cmd.env("PERRY_LLVM_KEEP_IR", "1"); + } + let compile = cmd.output().expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + let stderr = String::from_utf8_lossy(&compile.stderr).into_owned(); + (dir, output, stderr) +} + +/// The IR evidence from the issue: the captured read must reach the specialized +/// guarded path, and the generic dispatcher must be gone entirely. +#[test] +fn captured_numeric_array_takes_the_specialized_path() { + let (_dir, _bin, stderr) = compile("ir", true); + + let ll_path = stderr + .lines() + .find_map(|line| line.split("kept LLVM IR: ").nth(1)) + .map(str::trim) + .map(PathBuf::from) + .unwrap_or_else(|| { + panic!("PERRY_LLVM_KEEP_IR=1 did not report an IR path\nstderr:\n{stderr}") + }); + let ir = std::fs::read_to_string(&ll_path).expect("read kept LLVM IR"); + let _ = std::fs::remove_file(&ll_path); + + // Counting CALL sites, not declares: a bare `declare` of an unused helper + // proves nothing either way. + let calls_to = |name: &str| { + ir.lines() + .filter(|line| line.contains(" call ") && line.contains(&format!("@{name}("))) + .count() + }; + + assert_eq!( + calls_to("js_dyn_index_get"), + 0, + "a captured `number[]` must not fall back to the fully generic index \ + dispatcher — that is the 27x regression #6369 is about" + ); + assert!( + calls_to("js_typed_feedback_packed_f64_range_loop_guard") > 0, + "the captured hot loop must reach the guarded packed-f64 path that the \ + identical array-as-a-parameter already gets" + ); +} + +/// Behaviour, on every shape the guard's generic fallback has to catch. Each +/// expectation is what Node prints for the same program. +#[test] +fn captured_array_semantics_match_spec() { + let (_dir, bin, _stderr) = compile("run", false); + + let run = Command::new(&bin).output().expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + "A=168\n\ + B=1,x,null,undefined\n\ + C=1;2;undefined;4;undefined;undefined;\n\ + D=undefined,undefined,20,undefined,undefined\n\ + E=6,60\n\ + F=10,NaN\n\ + G=0,3,6,9\n", + "a captured array must keep exact JS semantics on mixed elements, holes, \ + OOB / negative / fractional / non-canonical keys, rebinding, shrinking, \ + and stores — the specialized path is only ever a guarded fast path" + ); +}