diff --git a/changelog.d/8872-ecs-cross-module-dispatch.md b/changelog.d/8872-ecs-cross-module-dispatch.md new file mode 100644 index 0000000000..d69e8b95c5 --- /dev/null +++ b/changelog.d/8872-ecs-cross-module-dispatch.md @@ -0,0 +1,14 @@ +Removed cross-module dispatch and argument-bundle overhead on the ECS command +path. Fourteen general compiler/runtime mechanisms: safe cross-module +free-function graph inlining, resolved Array header reuse across indexed +stores, split dynamic canonical Array read keys, guarded direct calls that +involve synthesized `arguments`, scalarized length-only `arguments` bundles, +direct captureless `Array.some` callbacks, inlined bounded tiny allocation +kernels and function-candidate optimization inside candidate methods, +preserved Map-entry types inside function bodies, trusted validated rooted +iterator headers, on-demand Array element-shape proofs, reused dynamic +all-pointer append proofs, inlined runtime-branded `Map.size`/`Set.size` +reads, and fully inlined equality against exact three-byte string literals. +On the upstream `codehz/ecs` "5k entities: 3 commands each + sync" row the +retained control moved from 8.610 ms to 7.287 ms per operation on the pinned +M1 Mac mini (15/15 paired wins, 30/30 semantic oracles). diff --git a/crates/perry-codegen/src/codegen/arguments.rs b/crates/perry-codegen/src/codegen/arguments.rs index 03fac7b3a4..231a58fde4 100644 --- a/crates/perry-codegen/src/codegen/arguments.rs +++ b/crates/perry-codegen/src/codegen/arguments.rs @@ -7,6 +7,19 @@ use crate::expr::{nanbox_pointer_inline, FnCtx}; use crate::nanbox::double_literal; use crate::types::{DOUBLE, I32, I64, PTR}; +/// Internal-only declared type used by the direct-call clone whose trailing +/// synthetic `arguments` slot carries the already boxed argument count. +/// Source HIR can never name this type: the marker is attached only to a +/// cloned method immediately before codegen. +pub(crate) const SYNTHETIC_ARGUMENTS_LENGTH_TYPE: &str = "__perry_arguments_length_scalar"; + +/// Additive direct-call ABI for methods proved to observe `arguments` only +/// through exact `.length` reads. The public method keeps its ordinary marked +/// Array/Arguments ABI for runtime dispatch and reflection. +pub(crate) fn arguments_length_method_name(public_name: &str) -> String { + format!("{public_name}$arguments_length") +} + pub(crate) enum ArgumentsCallee<'a> { Undefined, FunctionWrapper(&'a str), @@ -143,6 +156,36 @@ fn arguments_used_only_for_length(body: &[Stmt], arguments_id: u32) -> bool { length_reads > 0 && total_uses == length_reads } +/// Whether a method may expose the scalar-count direct-call clone. +/// +/// This is deliberately stricter than the materialization elision above. A +/// user rest parameter still needs its own array, and a nested closure may +/// outlive the direct call, so both shapes remain on the public ABI even when +/// every syntactic use happens to be a `.length` read. +pub(crate) fn method_supports_arguments_length_direct_abi(method: &perry_hir::Function) -> bool { + let Some(synth_param) = method + .params + .last() + .filter(|p| p.arguments_object.is_some()) + else { + return false; + }; + if method + .params + .iter() + .any(|p| p.is_rest && p.arguments_object.is_none()) + { + return false; + } + let mut captured = false; + crate::collectors::for_each_expr_in_stmts(&method.body, &mut |expr| { + if let Expr::Closure { captures, .. } = expr { + captured |= captures.contains(&synth_param.id); + } + }); + !captured && arguments_used_only_for_length(&method.body, synth_param.id) +} + fn mapped_arguments_params(params: &[Param]) -> Vec<(u32, u32)> { params .iter() diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 4212abeaf2..19f5961df2 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -1086,6 +1086,7 @@ pub(super) fn compile_closure( method_param_counts: &cross_module.method_param_counts, method_has_rest: &cross_module.method_has_rest, method_has_synthetic_arguments: &cross_module.method_has_synthetic_arguments, + method_arguments_length_only: &cross_module.method_arguments_length_only, imported_func_return_types: &cross_module.imported_func_return_types, ffi_signatures: &cross_module.ffi_signatures, ffi_aliases: &cross_module.ffi_aliases, diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 943f7f6bb4..64c96ecb64 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -850,6 +850,7 @@ pub(super) fn compile_module_entry( method_param_counts: &cross_module.method_param_counts, method_has_rest: &cross_module.method_has_rest, method_has_synthetic_arguments: &cross_module.method_has_synthetic_arguments, + method_arguments_length_only: &cross_module.method_arguments_length_only, imported_func_return_types: &cross_module.imported_func_return_types, ffi_signatures: &cross_module.ffi_signatures, ffi_aliases: &cross_module.ffi_aliases, @@ -1561,6 +1562,7 @@ pub(super) fn compile_module_entry( method_param_counts: &cross_module.method_param_counts, method_has_rest: &cross_module.method_has_rest, method_has_synthetic_arguments: &cross_module.method_has_synthetic_arguments, + method_arguments_length_only: &cross_module.method_arguments_length_only, imported_func_return_types: &cross_module.imported_func_return_types, ffi_signatures: &cross_module.ffi_signatures, ffi_aliases: &cross_module.ffi_aliases, diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 4c01eda022..d761abc0d6 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -1104,6 +1104,7 @@ pub(super) fn compile_function( method_param_counts: &cross_module.method_param_counts, method_has_rest: &cross_module.method_has_rest, method_has_synthetic_arguments: &cross_module.method_has_synthetic_arguments, + method_arguments_length_only: &cross_module.method_arguments_length_only, imported_func_return_types: &cross_module.imported_func_return_types, ffi_signatures: &cross_module.ffi_signatures, ffi_aliases: &cross_module.ffi_aliases, diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 3435682882..82b472e04d 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -68,6 +68,13 @@ pub(super) fn compile_method( method.name ) })?; + let arguments_length_clone = method.params.last().is_some_and(|param| { + matches!( + ¶m.ty, + perry_hir::types::Type::Named(name) + if name == super::arguments::SYNTHETIC_ARGUMENTS_LENGTH_TYPE + ) + }); // Representation-selection Phase 5a: the proven-`this` clone is a SECOND, // additive body compiled from the same HIR through the same statement // lowerer. It never replaces the public symbol and never participates in @@ -82,14 +89,15 @@ pub(super) fn compile_method( .get(&(class.name.clone(), method.name.clone())) }) .flatten(); - let guarded_undefined_param = (!is_index_clone && !ptr_array_cache_clone && !pshape_arg_clone) - .then(|| { - cross_module - .guarded_undefined_method_params - .get(&(class.name.clone(), method.name.clone())) - .copied() - }) - .flatten(); + let guarded_undefined_param = + (!arguments_length_clone && !is_index_clone && !ptr_array_cache_clone && !pshape_arg_clone) + .then(|| { + cross_module + .guarded_undefined_method_params + .get(&(class.name.clone(), method.name.clone())) + .copied() + }) + .flatten(); let fast_array_param_ids = if fast_array_handle_clone { crate::codegen::typed_abi::nonnegative_index_fast_array_params( method, @@ -110,7 +118,9 @@ pub(super) fn compile_method( debug_assert!(!pshape_arg_clone || !ptr_array_cache_clone); debug_assert!(!pshape_arg_clone || typed_public_trampoline.is_none()); debug_assert!(!pshape_arg_clone || !force_generic_body); - let family_name = if pshape_arg_clone { + let family_name = if arguments_length_clone { + super::arguments::arguments_length_method_name(&public_llvm_name) + } else if pshape_arg_clone { crate::collectors::pshape_args_method_name(&public_llvm_name) } else if ptr_array_cache_clone { crate::collectors::ptr_array_cache_method_name(&public_llvm_name) @@ -119,7 +129,9 @@ pub(super) fn compile_method( } else { public_llvm_name.clone() }; - let llvm_name = if fast_array_handle_clone { + let llvm_name = if arguments_length_clone { + family_name.clone() + } else if fast_array_handle_clone { crate::codegen::nonnegative_index_fast_array_method_name( &public_llvm_name, nonnegative_index_params.expect("fast-array clone has index parameters"), @@ -174,6 +186,13 @@ pub(super) fn compile_method( if is_index_clone { lf.pre_statepoint_inline = true; } + // #8872: methods participate in the same allocation-hot analysis as + // functions and closures. This must be set before the entry block exists + // because `lower_call/new_alloc.rs` consults it while lowering each `new` + // site. Previously `collect_alloc_hot_functions` could discover a method + // FuncId, but method codegen silently discarded the result, leaving tiny + // cross-module allocation kernels on the outlined runtime allocator. + lf.alloc_hot = cross_module.alloc_hot_functions.contains(&method.id); // gh #6206 / #6081: methods were compiled WITHOUT a shadow frame — same // exact-roots liveness hole as closures (see compile_closure). One extra @@ -439,6 +458,7 @@ pub(super) fn compile_method( method_param_counts: &cross_module.method_param_counts, method_has_rest: &cross_module.method_has_rest, method_has_synthetic_arguments: &cross_module.method_has_synthetic_arguments, + method_arguments_length_only: &cross_module.method_arguments_length_only, imported_func_return_types: &cross_module.imported_func_return_types, ffi_signatures: &cross_module.ffi_signatures, ffi_aliases: &cross_module.ffi_aliases, @@ -1221,7 +1241,11 @@ pub(super) fn compile_method( // twice. if let Some(param_index) = guarded_undefined_param.filter(|_| !guarded_undefined_clone) { emit_guarded_undefined(llmod, method, &family_name, &llvm_name, param_index); - } else if !is_pshape_clone && !is_index_clone && !guarded_undefined_clone { + } else if !arguments_length_clone + && !is_pshape_clone + && !is_index_clone + && !guarded_undefined_clone + { if let Some(kind) = typed_public_trampoline { emit_public_typed(llmod, method, &public_llvm_name, &llvm_name, kind); } else if force_generic_body { @@ -1732,6 +1756,7 @@ pub(super) fn compile_static_method( method_param_counts: &cross_module.method_param_counts, method_has_rest: &cross_module.method_has_rest, method_has_synthetic_arguments: &cross_module.method_has_synthetic_arguments, + method_arguments_length_only: &cross_module.method_arguments_length_only, imported_func_return_types: &cross_module.imported_func_return_types, ffi_signatures: &cross_module.ffi_signatures, ffi_aliases: &cross_module.ffi_aliases, diff --git a/crates/perry-codegen/src/codegen/method_registry.rs b/crates/perry-codegen/src/codegen/method_registry.rs index a1c1b2d43b..60d2a024ca 100644 --- a/crates/perry-codegen/src/codegen/method_registry.rs +++ b/crates/perry-codegen/src/codegen/method_registry.rs @@ -220,6 +220,15 @@ pub(crate) fn build_method_names( let clone = crate::collectors::pshape_method_name(&llvm_fn); llmod.declare_function(&clone, DOUBLE, ¶m_types); } + if ic + .method_arguments_length_only + .get(method_idx) + .copied() + .unwrap_or(false) + { + let clone = super::arguments::arguments_length_method_name(&llvm_fn); + llmod.declare_function(&clone, DOUBLE, ¶m_types); + } } // Cross-module getters. The dispatch site at diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index d800568c1e..778cd28173 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -1549,6 +1549,8 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> std::collections::HashMap::new(); let mut method_has_synthetic_arguments: std::collections::HashMap<(String, String), bool> = std::collections::HashMap::new(); + let mut method_arguments_length_only: std::collections::HashMap<(String, String), bool> = + std::collections::HashMap::new(); for cls in &hir.classes { for m in &cls.methods { let key = (cls.name.clone(), m.name.clone()); @@ -1561,7 +1563,10 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> .last() .is_some_and(|param| param.arguments_object.is_some()) { - method_has_synthetic_arguments.insert(key, true); + method_has_synthetic_arguments.insert(key.clone(), true); + } + if arguments::method_supports_arguments_length_direct_abi(m) { + method_arguments_length_only.insert(key, true); } } // Issue #894: track static methods too. Effect's `static pipe()` / @@ -1622,6 +1627,18 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> .insert((effective_name.clone(), mname.clone()), true); } } + if ic + .method_arguments_length_only + .get(i) + .copied() + .unwrap_or(false) + { + method_arguments_length_only.insert((ic.name.clone(), mname.clone()), true); + if effective_name != ic.name { + method_arguments_length_only + .insert((effective_name.clone(), mname.clone()), true); + } + } } for (i, method_name) in ic.static_method_names.iter().enumerate() { let registry_name = static_method_registry_key(method_name); @@ -2333,6 +2350,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> method_param_counts, method_has_rest, method_has_synthetic_arguments, + method_arguments_length_only, class_keys_globals: class_keys_globals_map, class_field_counts: class_field_counts_map, class_init_chains: class_init_chains_map, diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index 194ea2c3c9..bf6820e518 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -562,6 +562,12 @@ pub struct ImportedClass { /// `arguments`. Unlike a user `...rest` slot, this slot receives every /// actual argument while the named parameters remain positional. pub method_has_synthetic_arguments: Vec, + /// Parallel to `method_names`. `true` is a producer-authored capability: + /// the method has no user rest parameter and observes its synthesized + /// `arguments` binding only through exact `.length` reads. Importers may + /// call the additive `$arguments_length` ABI with a scalar count instead + /// of allocating and filling an argument bundle. + pub method_arguments_length_only: Vec, /// Static field names defined on this class. Used to declare the foreign /// `@perry_static_____` global with external linkage /// so cross-module `[Parent.Symbol.X] = …` reads/writes resolve to the @@ -839,6 +845,9 @@ pub(crate) struct CrossModuleCtx { /// synthetic slot receives all actual arguments rather than only the /// values after the visible parameters. pub method_has_synthetic_arguments: std::collections::HashMap<(String, String), bool>, + /// Producer-proved scalar-count direct-call capability for synthetic + /// `arguments` methods. Sparse map (only `true` entries stored). + pub method_arguments_length_only: std::collections::HashMap<(String, String), bool>, /// Per-class `keys_array` global variable names. Each entry maps /// `class_name → @perry_class_keys___`. /// Built once in `compile_module` (one entry per class — local diff --git a/crates/perry-codegen/src/codegen/ordinary_method_artifacts.rs b/crates/perry-codegen/src/codegen/ordinary_method_artifacts.rs index 1837a38aab..cf15ddd289 100644 --- a/crates/perry-codegen/src/codegen/ordinary_method_artifacts.rs +++ b/crates/perry-codegen/src/codegen/ordinary_method_artifacts.rs @@ -91,6 +91,57 @@ pub(super) fn compile_ordinary_method_artifacts( ) .with_context(|| format!("lowering method '{}::{}'", class.name, method.name))?; + // A separate externally callable body keeps the public/runtime ABI exact: + // dynamic dispatch still receives a marked argument bundle, while a + // guarded direct caller may pass the actual argument count in the same + // trailing tagged-value slot. The internal marker type makes exact + // `arguments.length` reads lower to that scalar without changing source + // HIR or teaching generic property dispatch about the specialized ABI. + if super::arguments::method_supports_arguments_length_direct_abi(method) { + let mut clone = method.clone(); + let synth_param = clone + .params + .last_mut() + .expect("length-only arguments method has a synthetic parameter"); + synth_param.ty = perry_hir::types::Type::Named( + super::arguments::SYNTHETIC_ARGUMENTS_LENGTH_TYPE.to_string(), + ); + compile_method( + llmod, + class, + &clone, + func_names, + strings, + classes, + methods, + module_globals, + module_global_types, + import_function_prefixes, + enums, + static_field_globals, + class_ids, + func_signatures, + func_synthetic_arguments, + module_boxed_vars, + closure_rest_params, + cross_module, + None, + false, + None, + None, + false, + false, + false, + false, + ) + .with_context(|| { + format!( + "lowering scalar arguments-length clone of method '{}::{}'", + class.name, method.name + ) + })?; + } + if cross_module .guarded_undefined_method_params .contains_key(&(class.name.clone(), method.name.clone())) diff --git a/crates/perry-codegen/src/collectors/hot_callees.rs b/crates/perry-codegen/src/collectors/hot_callees.rs index 3683ed6b15..572f524d9b 100644 --- a/crates/perry-codegen/src/collectors/hot_callees.rs +++ b/crates/perry-codegen/src/collectors/hot_callees.rs @@ -46,6 +46,24 @@ struct HotCalleeScan { /// module's speculative growth to ~2.1 KiB. const INDIRECT_CLOSURE_ALLOC_SITE_BUDGET: u32 = 8; +/// #8872 follow-up: tiny allocation-bearing instance methods are allocation +/// kernels even when their callers live in another module and this module's +/// lexical loop scan cannot see them. Keep the admission deliberately +/// bounded on both axes that contribute code size: +/// +/// * at most two HIR statements per method, so this does not become a generic +/// "methods are hot" rule; and +/// * at most eight admitted `new` sites across the module, the same ~2.1 KiB +/// worst-case budget used for indirect closure calls above. +/// +/// The motivating shape is a command-buffer method whose whole body is an +/// optional-argument prologue plus `commands.push({ ... })`. Cross-module +/// callers make call-site hotness invisible here, but the method itself is a +/// stable, reusable allocation site. The outlined allocator is semantically +/// identical, so declining modules over the budget is a safe under-inclusion. +const TINY_METHOD_MAX_STMTS: usize = 2; +const TINY_METHOD_ALLOC_SITE_BUDGET: u32 = 8; + /// Collect the set of `FuncId`s eligible for `inlinehint`: those with ≥1 direct /// call site inside a loop AND at most `max_call_sites` total direct call sites /// across the whole module (`init` + every function + every executable @@ -132,7 +150,7 @@ pub fn collect_hot_loop_callees(hir: &Module, max_call_sites: u32) -> HashSet HashSet HashSet { .filter_map(|(&func_id, &sites)| (sites > 0).then_some(func_id)), ); } + // Rule 4: a tiny method that exists chiefly to construct and publish a + // value is its own allocation kernel. Count only allocations owned by the + // method body; `count_alloc_sites_in_stmts` switches ownership at closure + // boundaries, so a nested callback's `new` is still governed by rule 3. + // Select all or none after counting to keep the result independent of + // class/method traversal order. + let mut tiny_method_sites: HashMap = HashMap::new(); + for class in &hir.classes { + for method in &class.methods { + if method.body.len() > TINY_METHOD_MAX_STMTS { + continue; + } + // Count into a scratch map because the ownership-aware walker also + // records nested closures. Only transfer the sites still owned by + // the method; closure-owned sites remain exclusively under rule 3. + let mut owned_sites = HashMap::new(); + count_alloc_sites_in_stmts(&method.body, Some(method.id), &mut owned_sites); + if let Some(sites) = owned_sites.get(&method.id).copied() { + tiny_method_sites.insert(method.id, sites); + } + } + } + tiny_method_sites.retain(|_, sites| *sites != 0); + let tiny_method_site_count = tiny_method_sites + .values() + .copied() + .fold(0_u32, u32::saturating_add); + if tiny_method_site_count > 0 && tiny_method_site_count <= TINY_METHOD_ALLOC_SITE_BUDGET { + hot.extend(tiny_method_sites.into_keys()); + } hot } diff --git a/crates/perry-codegen/src/expr/array_callback_shape_tests.rs b/crates/perry-codegen/src/expr/array_callback_shape_tests.rs index 893baa3ba7..5418bcbfc5 100644 --- a/crates/perry-codegen/src/expr/array_callback_shape_tests.rs +++ b/crates/perry-codegen/src/expr/array_callback_shape_tests.rs @@ -170,3 +170,63 @@ fn callback_source_array_alias_keeps_the_shape_guard() { "declaring the source-array argument must deny the cross-boundary fact:\n{callback}" ); } + +fn module_with_some_callback(captures_this: bool) -> Module { + let callback = Expr::Closure { + func_id: CLOSURE_ID, + params: vec![param(ELEMENT_ID, "row", Type::Named("Row".to_string()))], + return_type: Type::Boolean, + body: vec![Stmt::Return(Some(Expr::Bool(true)))], + captures: Vec::new(), + mutable_captures: Vec::new(), + captures_this, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_async: false, + is_generator: false, + is_strict: false, + }; + let mut module = Module::new("array_some_captureless.ts"); + module.classes = vec![row_class()]; + module.init = vec![ + Stmt::Let { + id: ARRAY_ID, + name: "rows".to_string(), + ty: Type::Array(Box::new(Type::Named("Row".to_string()))), + mutable: false, + init: Some(Expr::Array(Vec::new())), + }, + Stmt::Expr(Expr::ArraySome { + array: Box::new(Expr::LocalGet(ARRAY_ID)), + callback: Box::new(callback), + }), + ]; + module.init_kind = ModuleInitKind::Eager; + module +} + +#[test] +fn captureless_inline_some_passes_the_callback_body_directly() { + let ir = emit(&module_with_some_callback(false)); + assert!( + ir.contains("call double @js_array_some_captureless") + && ir.contains("ptr @perry_closure_array_some_captureless_ts__99"), + "a captureless inline arrow should pass its body symbol directly:\n{ir}" + ); + assert!( + !ir.contains("call i64 @js_closure_alloc_singleton") + && !ir.contains("call double @js_array_some("), + "the direct some path must not materialize or dynamically dispatch a closure:\n{ir}" + ); +} + +#[test] +fn lexical_this_some_callback_keeps_the_closure_path() { + let ir = emit(&module_with_some_callback(true)); + assert!( + !ir.contains("call double @js_array_some_captureless") + && ir.contains("call double @js_array_some("), + "a callback with lexical-this state must retain its real closure environment:\n{ir}" + ); +} diff --git a/crates/perry-codegen/src/expr/array_push.rs b/crates/perry-codegen/src/expr/array_push.rs index cb97ddb3c0..946b867200 100644 --- a/crates/perry-codegen/src/expr/array_push.rs +++ b/crates/perry-codegen/src/expr/array_push.rs @@ -144,6 +144,91 @@ fn guarded_numeric_add_push_candidate(ctx: &FnCtx<'_>, value: &Expr) -> bool { /// `0x0407 | 0x3800` == `0x3C07` == 15367. const ARRAY_PUSH_NUMERIC_CLEAN_I16: &str = "15367"; +/// Header admission mask for the dynamic pointer-append bookkeeping fast arm. +/// +/// A generic value cannot use #7469's static all-pointer-array tier, but its +/// live bits can still prove `POINTER_TAG` at the store. When the receiver also +/// carries `SIDE_MASK | ALL_POINTERS`, with both raw-f64 flags and the optional +/// homogeneous element-shape proof clear, the three generic calls are dead: +/// +/// * a `POINTER_TAG` value is not a heap string, so no string addref is needed; +/// * appending it preserves the all-pointer GC layout; +/// * both raw-f64 flags are already clear, so the numeric-layout note is a +/// no-op. +/// +/// `0xF880` is `LAYOUT_STATE_MASK | ALL_POINTERS | RAW_F64_HOLES | +/// ELEMENT_SHAPE | RAW_F64_LAYOUT`; the admitted value is exactly +/// `SIDE_MASK | ALL_POINTERS` (`0xA000`). Integrity and prototype cleanliness +/// are checked by the enclosing `apush.nofwd` block as before. +const ARRAY_PUSH_POINTER_LAYOUT_MASK_I16: &str = "63616"; +const ARRAY_PUSH_POINTER_LAYOUT_EXPECT_I16: &str = "40960"; + +/// Store a dynamically typed append value and bypass redundant bookkeeping +/// when the value and receiver's live header jointly prove the pointer-only +/// case. The write barrier is intentionally not handled here: an all-pointer +/// layout says which slots the collector scans, not that an old parent cannot +/// receive a young child, so the caller retains its generation-tested barrier. +#[allow(clippy::too_many_arguments)] +fn emit_dynamic_pointer_push_store( + ctx: &mut FnCtx<'_>, + arr_handle: &str, + value_double: &str, + value_bits_override: Option<&str>, + object_flags: &str, + string_addref_needed: bool, + layout_note_needed: bool, +) -> (String, String, String) { + let (length, element_addr, value_bits) = { + let blk = ctx.block(); + let length = blk.safe_load_i32_from_ptr(arr_handle); + let length_i64 = blk.zext(I32, &length, I64); + let byte_offset = blk.shl(I64, &length_i64, "3"); + let with_header = blk.add(I64, &byte_offset, "8"); + let element_addr = blk.add(I64, arr_handle, &with_header); + let element_ptr = blk.inttoptr(I64, &element_addr); + // GC_STORE_AUDIT(BARRIERED): the common store remains unconditional; + // only proven no-op bookkeeping is bypassed below, and the caller + // still emits the generation-tested write barrier. + blk.store(DOUBLE, value_double, &element_ptr); + let value_bits = value_bits_override + .map(ToOwned::to_owned) + .unwrap_or_else(|| blk.bitcast_double_to_i64(value_double)); + (length, element_addr, value_bits) + }; + + let bookkeeping_idx = ctx.new_block("apush.pointer_layout.bookkeeping"); + let done_idx = ctx.new_block("apush.pointer_layout.done"); + let bookkeeping_label = ctx.block_label(bookkeeping_idx); + let done_label = ctx.block_label(done_idx); + { + let blk = ctx.block(); + let top16 = blk.lshr(I64, &value_bits, "48"); + let is_object_pointer = blk.icmp_eq(I64, &top16, crate::nanbox::POINTER_TAG_TOP16_I64); + let proof_bits = blk.and(I16, object_flags, ARRAY_PUSH_POINTER_LAYOUT_MASK_I16); + let pointer_layout = blk.icmp_eq(I16, &proof_bits, ARRAY_PUSH_POINTER_LAYOUT_EXPECT_I16); + let fast = blk.and(I1, &is_object_pointer, &pointer_layout); + blk.cond_br(&fast, &done_label, &bookkeeping_label); + } + + ctx.current_block = bookkeeping_idx; + { + let blk = ctx.block(); + if string_addref_needed { + blk.call_void("js_string_addref_if_heap_string", &[(DOUBLE, value_double)]); + } + if layout_note_needed { + emit_layout_note_slot_on_block(blk, arr_handle, &length, &value_bits); + } + // This helper is selected only for a value whose static construction + // cannot prove numeric. The generic path therefore carried this note + // before, and still does whenever the joint live proof fails. + emit_array_numeric_write_note_on_block(blk, arr_handle, &value_bits); + blk.br(&done_label); + } + ctx.current_block = done_idx; + (length, element_addr, value_bits) +} + /// #7839 — the inline array append's GC bookkeeping behind ONE live test. /// /// The `apush.inbounds` store used to pay `js_string_addref_if_heap_string` + @@ -897,12 +982,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, value_discarded: bool) -> // the GcHeader `_reserved` u16 at `arr - 6` (obj_type u8 at -8, // gc_flags u8 at -7, `_reserved` u16 at -6): mask // FROZEN|SEALED|NO_EXTEND|ARRAY_DESCRIPTORS = 0x407. + let live_object_flags: String; ctx.current_block = nofwd_idx; { let blk = ctx.block(); let flags_addr = blk.sub(I64, &arr_handle, "6"); let flags_ptr = blk.inttoptr(I64, &flags_addr); let obj_flags = blk.load(I16, &flags_ptr); + live_object_flags = obj_flags.clone(); let clean = if declared_all_pointer { // #7469 — the elided-bookkeeping admission test. Same // `_reserved` load, same one `and` + one `icmp` as the @@ -996,6 +1083,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, value_discarded: bool) -> // between the store and the barrier would run with the // old→young edge unrecorded. The block is split here rather // than the call being sunk to the end of the block. + let dynamic_pointer_bookkeeping = + !declared_all_pointer && !value_is_statically_numeric && layout_note_needed; let (length, element_addr, barrier_value_bits) = if guarded_numeric_bookkeeping { emit_numeric_push_store_pointer_tested( ctx, @@ -1006,6 +1095,21 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, value_discarded: bool) -> layout_note_needed, write_barrier_needed, ) + } else if dynamic_pointer_bookkeeping { + let (length, element_addr, value_bits) = emit_dynamic_pointer_push_store( + ctx, + &arr_handle, + &v, + v_bits.as_deref(), + &live_object_flags, + string_addref_needed, + layout_note_needed, + ); + ( + length, + element_addr, + write_barrier_needed.then_some(value_bits), + ) } else { let blk = ctx.block(); let length = blk.safe_load_i32_from_ptr(&arr_handle); diff --git a/crates/perry-codegen/src/expr/array_push_guard_tests.rs b/crates/perry-codegen/src/expr/array_push_guard_tests.rs index bc742e9bb5..38e280a006 100644 --- a/crates/perry-codegen/src/expr/array_push_guard_tests.rs +++ b/crates/perry-codegen/src/expr/array_push_guard_tests.rs @@ -32,10 +32,13 @@ use perry_hir::{ const GUARD_BLOCK: &str = "apush.gc_bookkeeping"; const NOTE_CALL: &str = "call void @js_gc_note_slot_layout("; const ADDREF_CALL: &str = "call void @js_string_addref_if_heap_string("; +const NUMERIC_NOTE_CALL: &str = "call void @js_array_note_numeric_write("; /// `ARRAY_PUSH_NUMERIC_CLEAN_I16` as it appears in the `nofwd` admission test. const WIDENED_ADMISSION_MASK: &str = "15367"; /// The historical integrity mask, which the numeric push must NOT still use. const NARROW_INTEGRITY_MASK: &str = ", 1031"; +const POINTER_LAYOUT_BLOCK: &str = "apush.pointer_layout.bookkeeping"; +const POINTER_LAYOUT_MASK: &str = "63616"; fn ir_opts() -> CompileOptions { CompileOptions { @@ -337,6 +340,30 @@ fn a_pointer_push_keeps_the_historical_unguarded_shape() { ); } +#[test] +fn a_dynamic_push_consumes_a_live_all_pointer_array_proof() { + let mut module = push_module(Type::Any, Expr::LocalGet(BASE_ID), Vec::new()); + module.functions[0].params[0].ty = Type::Any; + let ir = ir_for(module); + let inbounds = inbounds_block(&ir); + assert!( + inbounds.contains(POINTER_LAYOUT_BLOCK), + "a dynamically typed append never emitted the live pointer/layout guard:\n{inbounds}" + ); + assert!( + inbounds.contains(POINTER_LAYOUT_MASK), + "the guard does not test the complete all-pointer/raw-f64/element-shape header mask:\n{inbounds}" + ); + assert!( + inbounds.contains(crate::nanbox::POINTER_TAG_TOP16_I64), + "the guard does not test the live value's exact POINTER_TAG:\n{inbounds}" + ); + assert!( + ir.contains(NOTE_CALL) && ir.contains(ADDREF_CALL) && ir.contains(NUMERIC_NOTE_CALL), + "the proof-miss arm must retain every generic bookkeeping call:\n{ir}" + ); +} + // --------------------------------------------------------------------------- // #7831/#7837 collision: an erased annotation is a hint, not a runtime proof. // --------------------------------------------------------------------------- diff --git a/crates/perry-codegen/src/expr/class_method_arguments_object_tests.rs b/crates/perry-codegen/src/expr/class_method_arguments_object_tests.rs index fd312182ac..419e93b1d3 100644 --- a/crates/perry-codegen/src/expr/class_method_arguments_object_tests.rs +++ b/crates/perry-codegen/src/expr/class_method_arguments_object_tests.rs @@ -11,7 +11,8 @@ //! the other shape: bundle from argument 0 and mark the array. //! //! This is an IR-census test on the CALL SITE, which is where the defect lived. -//! Both halves matter and both are asserted: +//! For methods which need the materialized object, both halves matter and both +//! are asserted: //! //! * the array is filled from argument 0 (three `js_array_push_f64` into the //! bundle for a three-argument call to a two-parameter method), and @@ -246,11 +247,20 @@ fn pushes_of(ir: &str, literal: &str) -> usize { .count() } -/// The regression. Three args, two declared params, body reads `arguments`: +/// The regression. Three args, two declared params, body reads `arguments[0]`: /// all three must be pushed into the bundle, and the bundle must be marked. +/// +/// The index read deliberately prevents the length-only direct ABI from +/// scalarizing the object. This continues to cover the materialized path which +/// originally lost the first two arguments in #8040. #[test] fn a_class_method_reading_arguments_is_handed_every_passed_argument() { - let ir = emit(&module_with_tail(synthetic_arguments_param())); + let mut module = module_with_tail(synthetic_arguments_param()); + module.classes[0].methods[0].body = vec![Stmt::Return(Some(Expr::IndexGet { + object: Box::new(Expr::LocalGet(TAIL_ID)), + index: Box::new(Expr::Integer(0)), + }))]; + let ir = emit(&module); assert!( ir.contains(MARK), @@ -281,6 +291,35 @@ fn a_class_method_reading_arguments_is_handed_every_passed_argument() { } } +/// A method which observes only `arguments.length` has an additive, guarded +/// direct ABI. It receives the actual count as a scalar while the registered +/// public method retains the ordinary arguments-object ABI for dynamic calls. +#[test] +fn a_length_only_class_method_direct_call_passes_the_scalar_count() { + let ir = emit(&module_with_tail(synthetic_arguments_param())); + + assert!( + ir.contains( + "call double @perry_method_class_method_arguments_ts__T__m$arguments_length(\ + double" + ) && ir.contains("double 1.0, double 2.0, double 3.0)"), + "the guarded direct path should call the length-only clone with the \ + three-argument count in its trailing slot:\n{ir}" + ); + assert!( + !ir.contains(MARK) + && !ir.contains("call i64 @js_array_alloc(") + && !ir.contains("call i64 @js_array_push_f64("), + "the length-only direct call should not materialize an arguments \ + object:\n{ir}" + ); + assert!( + ir.contains("ptrtoint ptr @perry_method_class_method_arguments_ts__T__m to i64"), + "runtime registration must continue to publish the public method, \ + never the scalar-only clone:\n{ir}" + ); +} + /// The safety half: a real `...rest` with no `arguments` read keeps the old /// shape. It bundles only the args PAST the declared params, and is not marked. /// diff --git a/crates/perry-codegen/src/expr/compare.rs b/crates/perry-codegen/src/expr/compare.rs index 3435d161a3..9e4bf53b45 100644 --- a/crates/perry-codegen/src/expr/compare.rs +++ b/crates/perry-codegen/src/expr/compare.rs @@ -148,7 +148,8 @@ pub(super) fn i8_literal(b: u8) -> String { /// correctly unequal), and a heap string whose `byte_len` or whose first / last /// byte differs from the literal's is unequal without reading a byte the length /// check has not already proved the header owns. Only a same-length, -/// same-endpoints heap string reaches `js_string_equals`. +/// same-endpoints heap string reaches `js_string_equals`, except that a +/// three-byte literal is settled by checking its one remaining middle byte. /// /// Returns an `i1` that is true iff the two operands are `===`. fn lower_string_literal_strict_eq( @@ -171,7 +172,8 @@ fn lower_string_literal_strict_eq( let len_idx = ctx.new_block("streqlit.len"); let b0_idx = (n >= 1).then(|| ctx.new_block("streqlit.b0")); let bl_idx = (n >= 2).then(|| ctx.new_block("streqlit.bl")); - let slow_idx = (n >= 3).then(|| ctx.new_block("streqlit.slow")); + let bm_idx = (n == 3).then(|| ctx.new_block("streqlit.bm")); + let slow_idx = (n >= 4).then(|| ctx.new_block("streqlit.slow")); let true_idx = ctx.new_block("streqlit.true"); let false_idx = ctx.new_block("streqlit.false"); let merge_idx = ctx.new_block("streqlit.merge"); @@ -184,6 +186,7 @@ fn lower_string_literal_strict_eq( let sso_l = sso_idx.map(|i| ctx.block_label(i)); let b0_l = b0_idx.map(|i| ctx.block_label(i)); let bl_l = bl_idx.map(|i| ctx.block_label(i)); + let bm_l = bm_idx.map(|i| ctx.block_label(i)); let slow_l = slow_idx.map(|i| ctx.block_label(i)); // Entry: pooled-pointer identity. This is the hot true case — the value @@ -247,11 +250,29 @@ fn lower_string_literal_strict_eq( let p = ctx.block().gep_inbounds(I8, &hdr_ptr, &[(I64, &off)]); let b = ctx.block().load(I8, &p); let ok = ctx.block().icmp_eq(I8, &b, &i8_literal(bytes[n - 1])); - let next = slow_l.clone().unwrap_or_else(|| true_l.clone()); + let next = bm_l + .clone() + .or_else(|| slow_l.clone()) + .unwrap_or_else(|| true_l.clone()); ctx.block().cond_br(&ok, &next, &false_l); } - // Same length, same endpoints, different pointer: a real content compare. + // A three-byte string has exactly one byte left after the endpoint + // checks. Comparing it here completely decides the hot discriminant + // shape (`cmd.type === "set"`) without entering `js_string_equals` and + // its general-length `memcmp` path. The prior length check proves this + // byte is present in the payload. + if let Some(idx) = bm_idx { + ctx.current_block = idx; + let off = (STRING_HEADER_SIZE + 1).to_string(); + let p = ctx.block().gep_inbounds(I8, &hdr_ptr, &[(I64, &off)]); + let b = ctx.block().load(I8, &p); + let ok = ctx.block().icmp_eq(I8, &b, &i8_literal(bytes[1])); + ctx.block().cond_br(&ok, &true_l, &false_l); + } + + // Same length, same endpoints, different pointer: literals of four or + // more bytes still need a real content compare. // Both operands are proven heap `StringHeader*` here, so this is the narrow // two-pointer helper, not the generic value-equality tower. let slow_arm = slow_idx.map(|idx| { diff --git a/crates/perry-codegen/src/expr/compare_tests.rs b/crates/perry-codegen/src/expr/compare_tests.rs index 08afb63337..b80300f941 100644 --- a/crates/perry-codegen/src/expr/compare_tests.rs +++ b/crates/perry-codegen/src/expr/compare_tests.rs @@ -346,6 +346,26 @@ fn strict_eq_against_a_string_literal_emits_the_inline_dispatch_and_no_js_eq_cal !ir.contains(JS_EQ_CALL), "js_eq call survived the inline literal dispatch:\n{ir}" ); + assert!( + ir.contains("streqlit.bm"), + "three-byte literal did not compare its remaining middle byte inline:\n{ir}" + ); + assert!( + !ir.contains("call i32 @js_string_equals("), + "three-byte literal retained the full string-equality helper:\n{ir}" + ); +} + +#[test] +fn longer_string_literal_keeps_the_full_content_fallback() { + let ir = cmp_ir( + "streq_long_lit", + CompareOp::Eq, + Expr::LocalGet(X), + Expr::String("destroy".to_string()), + ); + assert!(ir.contains("streqlit.slow"), "{ir}"); + assert!(ir.contains("call i32 @js_string_equals("), "{ir}"); } #[test] diff --git a/crates/perry-codegen/src/expr/index_get.rs b/crates/perry-codegen/src/expr/index_get.rs index 5c007fa0f0..7518efc992 100644 --- a/crates/perry-codegen/src/expr/index_get.rs +++ b/crates/perry-codegen/src/expr/index_get.rs @@ -286,6 +286,103 @@ fn lower_array_index_get_via_runtime_key( } } +/// Split a dynamic numeric array key into the signed-i32 element tier and the +/// full JavaScript property-key tier without speculatively truncating it. +/// +/// A numeric type annotation does not prove array-index semantics: fractional, +/// negative, non-finite and large integral values are all named properties (or +/// out-of-range indices), not signed-i32 elements. At the same time, branded +/// numeric aliases and number-returning calls frequently lose the static range +/// fact that would let [`numeric_index_has_integer_array_index_proof`] select +/// the guarded element load. Recognize the profitable subset at runtime and +/// leave every rejected value on the existing exact helper. +/// +/// The `select` of `0.0` is load-bearing: LLVM `fptosi` is poison for NaN and +/// out-of-range inputs, so conversion must consume the range-sanitized value, +/// not merely be followed by a range branch. +fn lower_array_index_get_via_canonical_i32_split( + ctx: &mut FnCtx<'_>, + arr_box: &str, + idx_double: &str, + require_numeric_layout: bool, + coerce_numeric_fallback: bool, + preserve_claimed_receiver_fallback: bool, + receiver_slot: Option<&str>, +) -> Result { + let element_idx = ctx.new_block("aidx.canonical"); + let runtime_idx = ctx.new_block("aidx.runtime_key"); + let merge_idx = ctx.new_block("aidx.dynamic_merge"); + let element_label = ctx.block_label(element_idx); + let runtime_label = ctx.block_label(runtime_idx); + let merge_label = ctx.block_label(merge_idx); + + let (idx_i32, is_canonical_i32) = { + let blk = ctx.block(); + + // Ordinary JS numbers are raw IEEE doubles. Comparisons reject NaN + // (including Perry's tagged values) and infinities before conversion. + let raw_ge_zero = blk.fcmp("oge", idx_double, "0.0"); + let raw_le_i32_max = blk.fcmp("ole", idx_double, "2147483647.0"); + let raw_in_range = blk.and(I1, &raw_ge_zero, &raw_le_i32_max); + let safe_raw = blk.select(I1, &raw_in_range, DOUBLE, idx_double, "0.0"); + let raw_i32 = blk.fptosi(DOUBLE, &safe_raw, I32); + let raw_round_trip = blk.sitofp(I32, &raw_i32, DOUBLE); + let raw_is_integral = blk.fcmp("oeq", &raw_round_trip, idx_double); + let raw_is_canonical = blk.and(I1, &raw_in_range, &raw_is_integral); + + // Runtime-produced integer values may use Perry's INT32 NaN-box. This + // is the same tag test used by `js_array_get_index_or_string`; negative + // payloads remain named-property keys and therefore take the fallback. + let bits = blk.bitcast_double_to_i64(idx_double); + let top16 = blk.lshr(I64, &bits, "48"); + let is_boxed_i32 = blk.icmp_eq(I64, &top16, crate::nanbox::INT32_TAG_TOP16_I64); + let boxed_i32 = blk.trunc(I64, &bits, I32); + let boxed_nonnegative = blk.icmp_sge(I32, &boxed_i32, "0"); + let boxed_is_canonical = blk.and(I1, &is_boxed_i32, &boxed_nonnegative); + + let canonical = blk.or(I1, &raw_is_canonical, &boxed_is_canonical); + let value = blk.select(I1, &is_boxed_i32, I32, &boxed_i32, &raw_i32); + (value, canonical) + }; + ctx.block() + .cond_br(&is_canonical_i32, &element_label, &runtime_label); + + ctx.current_block = element_idx; + let element_value = lower_guarded_array_index_get( + ctx, + arr_box, + &idx_i32, + "aidx.dynamic", + require_numeric_layout, + coerce_numeric_fallback, + receiver_slot, + )?; + let element_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = runtime_idx; + let runtime_value = if preserve_claimed_receiver_fallback { + // An erased Array declaration is a claim rather than a receiver-tag + // proof. Keep the established SSO-string receiver arm for the exact + // property-key fallback; only the guarded canonical tier may consume + // the receiver as an array without first classifying it. + lower_claimable_array_string_key_get(ctx, arr_box, idx_double) + } else { + lower_array_index_get_via_runtime_key(ctx, arr_box, idx_double, coerce_numeric_fallback) + }; + let runtime_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = merge_idx; + Ok(ctx.block().phi( + DOUBLE, + &[ + (&element_value, &element_end), + (&runtime_value, &runtime_end), + ], + )) +} + /// Read a string-valued key from a receiver admitted by an erased Array type. /// /// The ordinary array ABI takes an already-unboxed `ArrayHeader*`, which loses @@ -563,9 +660,16 @@ pub(crate) fn lower_numeric_index_get_for_number_context( let repair_slot = receiver_repair_slot(ctx, object); if !numeric_index_has_integer_array_index_proof(ctx, index) { return rooting::with_operands_rooted(ctx, &[object, index], |ctx, vals| { - Ok(Some(lower_array_index_get_via_runtime_key( - ctx, &vals[0], &vals[1], true, - ))) + lower_array_index_get_via_canonical_i32_split( + ctx, + &vals[0], + &vals[1], + true, + true, + false, + repair_slot.as_deref(), + ) + .map(Some) }); } rooting::with_operands_rooted_across( @@ -1328,30 +1432,54 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // // #7640 section B: `!is_numeric_expr` also does not restrict // the index to a safe shape — `arr[f()]` is exactly this arm. + if index_is_static_string_or_symbol { + return rooting::with_operands_rooted( + ctx, + &[object, index], + |ctx, vals| { + Ok(lower_claimable_array_string_key_get( + ctx, &vals[0], &vals[1], + )) + }, + ); + } + + // Generic/branded keys can still carry an ordinary number + // at runtime (ComponentId is a common example). Split + // those canonical values into the guarded element tier, + // while retaining the boxed-receiver helper for every + // string, symbol, object and rejected number key. + let repair_slot = receiver_repair_slot(ctx, object); return rooting::with_operands_rooted(ctx, &[object, index], |ctx, vals| { - let (arr_box, idx_double) = (vals[0].clone(), vals[1].clone()); - Ok(lower_claimable_array_string_key_get( + lower_array_index_get_via_canonical_i32_split( ctx, - &arr_box, - &idx_double, - )) + &vals[0], + &vals[1], + false, + false, + true, + repair_slot.as_deref(), + ) }); } if numeric_index_needs_runtime_key(ctx, object.as_ref(), index.as_ref()) { // #7640 section B: `is_numeric_expr` is a TYPE predicate, // not an effect-free one — a numeric-typed but unproven // dynamic index (a getter, a call) is this arm's target. + // Preserve full property-key semantics for rejected values, + // but recover the guarded element tier for runtime-proven + // canonical signed-i32 keys (notably branded number IDs). + let repair_slot = receiver_repair_slot(ctx, object); return rooting::with_operands_rooted(ctx, &[object, index], |ctx, vals| { - let (arr_box, idx_double) = (vals[0].clone(), vals[1].clone()); - let arr_handle = { - let blk = ctx.block(); - unbox_to_i64(blk, &arr_box) - }; - Ok(ctx.block().call( - DOUBLE, - "js_array_get_index_or_string", - &[(I64, &arr_handle), (DOUBLE, &idx_double)], - )) + lower_array_index_get_via_canonical_i32_split( + ctx, + &vals[0], + &vals[1], + false, + false, + false, + repair_slot.as_deref(), + ) }); } let require_numeric_layout = diff --git a/crates/perry-codegen/src/expr/index_get_claim_tests.rs b/crates/perry-codegen/src/expr/index_get_claim_tests.rs index fe25b8dad5..fc372e1495 100644 --- a/crates/perry-codegen/src/expr/index_get_claim_tests.rs +++ b/crates/perry-codegen/src/expr/index_get_claim_tests.rs @@ -5,11 +5,13 @@ //! must keep the guarded array tier whose receiver checks make that claim safe. use crate::temp_root_coverage::main_ir_for as ir_for; +use crate::{compile_module, CompileOptions}; use perry_hir::types::Type; -use perry_hir::{Expr, Stmt}; +use perry_hir::{Expr, Function, Module, Param, Stmt}; const ITEMS: u32 = 1; const RESULT: u32 = 2; +const KEY: u32 = 3; fn declared_array_read_ir(name: &str, index: Expr) -> String { ir_for( @@ -73,3 +75,86 @@ fn numeric_key_on_a_declared_array_keeps_the_guarded_array_tier() { "the SSO receiver guard widened onto the numeric array path:\n{ir}" ); } + +fn dynamic_key_read_ir(name: &str, key_type: Type) -> String { + let param = |id, name: &str, ty| Param { + id, + name: name.to_string(), + ty, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }; + let mut module = Module::new(name); + module.functions.push(Function { + id: 10, + name: "read".to_string(), + type_params: Vec::new(), + params: vec![ + param(ITEMS, "items", Type::Array(Box::new(Type::Any))), + param(KEY, "key", key_type), + ], + return_type: Type::Any, + body: vec![Stmt::Return(Some(Expr::IndexGet { + object: Box::new(Expr::LocalGet(ITEMS)), + index: Box::new(Expr::LocalGet(KEY)), + }))], + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }); + String::from_utf8( + compile_module( + &module, + CompileOptions { + emit_ir_only: true, + ..Default::default() + }, + ) + .expect("dynamic key fixture compiles"), + ) + .expect("LLVM IR is UTF-8") +} + +#[test] +fn dynamic_number_key_splits_canonical_indices_from_exact_property_keys() { + // A number parameter has no compile-time integral/range proof. + let ir = dynamic_key_read_ir("declared_array_dynamic_number_key.ts", Type::Number); + + assert!( + ir.contains("aidx.canonical") && ir.contains("aidx.dynamic.guard.deref"), + "the runtime-proven canonical-index guarded tier was not emitted:\n{ir}" + ); + assert!( + ir.contains("aidx.runtime_key") + && ir.contains("call double @js_array_get_index_or_string("), + "the exact noncanonical property-key fallback disappeared:\n{ir}" + ); + assert!( + ir.contains("select i1") && ir.contains("fptosi double"), + "the poison-safe range sanitization before fptosi was not emitted:\n{ir}" + ); +} + +#[test] +fn generic_key_recovers_numeric_elements_without_losing_claim_safe_fallback() { + let ir = dynamic_key_read_ir("declared_array_generic_key.ts", Type::Any); + + assert!( + ir.contains("aidx.canonical") && ir.contains("aidx.dynamic.guard.deref"), + "the generic key's runtime-proven numeric tier was not emitted:\n{ir}" + ); + assert!( + ir.contains("aidx.runtime_key") + && ir.contains("aidxkey.sso") + && ir.contains("call double @js_string_index_get_boxed(") + && ir.contains("call double @js_array_get_index_or_string("), + "the generic key lost its exact boxed-receiver fallback:\n{ir}" + ); +} diff --git a/crates/perry-codegen/src/expr/logical_collections.rs b/crates/perry-codegen/src/expr/logical_collections.rs index 590252b274..0375d2fdbd 100644 --- a/crates/perry-codegen/src/expr/logical_collections.rs +++ b/crates/perry-codegen/src/expr/logical_collections.rs @@ -72,6 +72,46 @@ fn is_static_number_key_map(ctx: &FnCtx<'_>, map: &Expr) -> bool { ) } +/// Return the compiled body symbol for an inline arrow whose function object +/// cannot be observed by `Array.prototype.some` and whose body cannot inspect +/// a closure environment. The runtime may then invoke the code pointer +/// directly without allocating/looking up a singleton ClosureHeader. +fn captureless_some_callback(ctx: &FnCtx<'_>, callback: &Expr) -> Option { + let Expr::Closure { + func_id, + params, + body, + captures, + captures_this, + captures_new_target, + is_arrow, + is_async, + is_generator, + .. + } = callback + else { + return None; + }; + if !is_arrow + || *is_async + || *is_generator + || *captures_this + || *captures_new_target + || params.len() > 3 + || params + .iter() + .any(|param| param.is_rest || param.arguments_object.is_some()) + || !crate::type_analysis::compute_auto_captures(ctx, params, body, captures).is_empty() + { + return None; + } + Some(format!( + "@perry_closure_{}__{}", + ctx.strings.module_prefix(), + func_id + )) +} + fn guarded_map_number_key_delete(ctx: &mut FnCtx<'_>, map_handle: &str, key_box: &str) -> String { let guard_raw = ctx .block() @@ -305,6 +345,15 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // js_array_some returns a NaN-tagged TAG_TRUE/TAG_FALSE as f64, // so we forward it directly without conversion. Expr::ArraySome { array, callback } => { + if let Some(callback_func) = captureless_some_callback(ctx, callback) { + let arr_box = lower_expr(ctx, array)?; + let arr_handle = unbox_to_i64(ctx.block(), &arr_box); + return Ok(ctx.block().call( + DOUBLE, + "js_array_some_captureless", + &[(I64, &arr_handle), (PTR, &callback_func)], + )); + } // #7615 slice 2: same callback window as `ArrayFilter` above. rooting::with_operands_rooted(ctx, &[array, callback], |ctx, vals| { let blk = ctx.block(); diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 2cc7baf037..f816ad8d6a 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -709,6 +709,8 @@ pub(crate) struct FnCtx<'a> { /// compiler-synthesized `arguments` binding and therefore receives every /// actual argument. pub method_has_synthetic_arguments: &'a std::collections::HashMap<(String, String), bool>, + /// Methods whose producer emitted a scalar `arguments.length` direct ABI. + pub method_arguments_length_only: &'a std::collections::HashMap<(String, String), bool>, /// Whole-program reverse capabilities for guarded short-spread method /// calls. See `CompileOptions::short_spread_method_candidates`. pub short_spread_method_candidates: diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index 8229d2f0c4..a69f8aa68a 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -106,6 +106,23 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { object, property, .. } = expr { + // Direct-call-only synthetic `arguments` clone: producer analysis + // proved that this exact `.length` form is the binding's sole use, and + // the caller placed the already boxed actual-argument count in its + // trailing slot. The public method retains normal Arguments semantics. + if property == "length" + && matches!( + object.as_ref(), + Expr::LocalGet(id) + if matches!( + ctx.local_type_hint(id), + Some(perry_hir::types::Type::Named(name)) + if name == crate::codegen::arguments::SYNTHETIC_ARGUMENTS_LENGTH_TYPE + ) + ) + { + return lower_expr(ctx, object); + } if property == "buffer" { if let Expr::LocalGet(id) = object.as_ref() { if ctx.buffer_view_slots.contains_key(id) { diff --git a/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs b/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs index 28bdc9c7f4..e255bc4eb0 100644 --- a/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs +++ b/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs @@ -210,6 +210,19 @@ pub(crate) fn lower_generic_property_get( } else { None }; + // A dynamically typed `receiver.size` can still be served without the + // object PIC when the live receiver is a native Map or Set. Both payloads + // start with the same `u32 size` field, and their distinct GcHeader kinds + // are checked below before the load. This is deliberately a runtime brand + // check rather than a TypeScript-type claim: nested structural reads such + // as `this.ctx.hooks.size` commonly lose their static Set type, while an + // erased annotation alone must never authorize a native-layout load. + let inline_collection_size = property == "size"; + let collection_size_idx = if inline_collection_size { + Some(ctx.new_block("pget.collection_size")) + } else { + None + }; // #7883: the POINTER/STRING test goes FIRST, and the two rare tags are // discriminated in a cold block off its false edge. The three tag classes // are pairwise disjoint — `is_valid` is `(tag & 0xFFFD) == 0x7FFD`, true @@ -370,6 +383,23 @@ pub(crate) fn lower_generic_property_get( let gc_type_addr = ctx.block().sub(I64, &obj_handle, "8"); let gc_type_ptr = ctx.block().inttoptr(I64, &gc_type_addr); let gc_type = ctx.block().load(I8, &gc_type_ptr); + + // `MapHeader` and `SetHeader` both begin with `size: u32`. A native + // collection is not an ObjectHeader and can never hit this PIC, so split + // it off immediately after the already-required GC-kind load. The generic + // miss handler recognizes the same two kinds before ordinary object + // lookup; this only removes that repeated classification and call ladder. + if let Some(collection_idx) = collection_size_idx { + let collection_label = ctx.block_label(collection_idx); + let object_check_idx = ctx.new_block("pic.recv_object_check"); + let object_check_label = ctx.block_label(object_check_idx); + let is_map = ctx.block().icmp_eq(I8, &gc_type, "8"); // GC_TYPE_MAP + let is_set = ctx.block().icmp_eq(I8, &gc_type, "12"); // GC_TYPE_SET + let is_collection = ctx.block().or(I1, &is_map, &is_set); + ctx.block() + .cond_br(&is_collection, &collection_label, &object_check_label); + ctx.current_block = object_check_idx; + } let is_object = ctx.block().icmp_eq(I8, &gc_type, "2"); // Closures and RegExp values have distinct GC kinds. Every @@ -678,6 +708,20 @@ pub(crate) fn lower_generic_property_get( let pic_end_label = ctx.block().label.clone(); ctx.block().br(&final_merge_label); + // Native Map/Set `.size`: their common leading field was admitted only by + // the exact live GC-kind checks above. Keep the read inline; calling + // `js_map_size` / `js_set_size` would reclassify the same receiver again. + let collection_size_arm = if let Some(collection_idx) = collection_size_idx { + ctx.current_block = collection_idx; + let size_i32 = ctx.block().safe_load_i32_from_ptr(&obj_handle); + let size = ctx.block().uitofp(I32, &size_i32, DOUBLE); + let collection_end_label = ctx.block().label.clone(); + ctx.block().br(&final_merge_label); + Some((size, collection_end_label)) + } else { + None + }; + // Invalid receiver: per JS spec, `undefined` and `null` // throw a TypeError; other non-pointer tags (int32, bool, // plain f64, bigint) should auto-box and look up via the @@ -794,5 +838,8 @@ pub(crate) fn lower_generic_property_get( if let Some((heap_len, heap_end_label)) = strlen_heap_arm.as_ref() { incoming.push((heap_len, heap_end_label)); } + if let Some((size, collection_end_label)) = collection_size_arm.as_ref() { + incoming.push((size, collection_end_label)); + } Ok(ctx.block().phi(DOUBLE, &incoming)) } diff --git a/crates/perry-codegen/src/expr/property_get/tests.rs b/crates/perry-codegen/src/expr/property_get/tests.rs index 8b4fc7e6d7..b92b3085e2 100644 --- a/crates/perry-codegen/src/expr/property_get/tests.rs +++ b/crates/perry-codegen/src/expr/property_get/tests.rs @@ -735,3 +735,44 @@ fn generic_non_length_read_keeps_the_whole_tower() { "a non-`length` SSO read must still call the by-name helper:\n{ir}" ); } + +/// A dynamically typed `.size` read must recognize native Map/Set receivers +/// by their live GC kinds before entering the object-only PIC. This covers +/// nested structural reads such as `this.ctx.hooks.size` without trusting an +/// erased TypeScript annotation as a native-layout proof. +#[test] +fn generic_size_read_serves_native_collections_inline() { + let ir = emit_read("size"); + let collection = ir + .find("\npget.collection_size") + .unwrap_or_else(|| panic!("expected a native collection size block:\n{ir}")); + let collection_body = &ir[collection..]; + let collection_end = collection_body[1..] + .find("\n\n") + .map(|i| i + 1) + .unwrap_or(collection_body.len()); + let collection_body = &collection_body[..collection_end]; + + assert!( + ir.contains("icmp eq i8") && ir.contains(", 8") && ir.contains(", 12"), + "the collection arm must be guarded by GC_TYPE_MAP and GC_TYPE_SET:\n{ir}" + ); + assert!( + collection_body.contains("load i32") && collection_body.contains("uitofp i32"), + "the branded collection arm must load the shared leading size field inline:\n\ + {collection_body}" + ); + assert!( + ir.contains("@perry_ic_") && ir.contains("js_object_get_field_ic_miss"), + "non-collection receivers must retain the generic property tower:\n{ir}" + ); +} + +#[test] +fn generic_non_size_read_has_no_collection_layout_load() { + let ir = emit_read("other"); + assert!( + !ir.contains("pget.collection_size") && !ir.contains("pic.recv_object_check"), + "only `.size` may grow the native collection fast path:\n{ir}" + ); +} diff --git a/crates/perry-codegen/src/expr/readonly_collection_tests.rs b/crates/perry-codegen/src/expr/readonly_collection_tests.rs index c18f6f3b80..c8efc147cf 100644 --- a/crates/perry-codegen/src/expr/readonly_collection_tests.rs +++ b/crates/perry-codegen/src/expr/readonly_collection_tests.rs @@ -189,6 +189,7 @@ fn imported_archetype() -> ImportedClass { method_param_counts: Vec::new(), method_has_rest: Vec::new(), method_has_synthetic_arguments: Vec::new(), + method_arguments_length_only: Vec::new(), static_field_names: Vec::new(), static_method_names: Vec::new(), static_method_return_types: Vec::new(), diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index 4e9dd6fa9d..34e865f910 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -99,6 +99,15 @@ pub fn exported_object_literal_method_capabilities( collectors::exported_object_literal_capabilities(hir) } +/// Return whether an instance method may publish the additive direct-call ABI +/// whose synthetic `arguments` slot carries only the actual argument count. +/// The compile driver uses the same producer-side proof when building import +/// metadata, so consumers never infer this capability from an incomplete +/// class stub. +pub fn method_supports_arguments_length_direct_abi(method: &perry_hir::Function) -> bool { + codegen::arguments::method_supports_arguments_length_direct_abi(method) +} + /// The shadow-stack field offsets generated code bakes into its inline root /// stores (#7088). /// diff --git a/crates/perry-codegen/src/lower_call/alloc_hot_tests.rs b/crates/perry-codegen/src/lower_call/alloc_hot_tests.rs index f0366a656e..52f3375f06 100644 --- a/crates/perry-codegen/src/lower_call/alloc_hot_tests.rs +++ b/crates/perry-codegen/src/lower_call/alloc_hot_tests.rs @@ -51,6 +51,7 @@ const HEADER_IMAGE_STORE: &str = "store <2 x i64> %"; const N_ID: u32 = 11; const WALK_ID: u32 = 700; +const FACTORY_METHOD_ID: u32 = 701; const STAGE_LOCAL_BASE: u32 = 800; const STAGE_FUNC_BASE: u32 = 900; @@ -147,6 +148,86 @@ fn cell_class() -> Class { } } +fn tiny_factory_class(prefix_stmts: usize) -> Class { + let mut body = Vec::new(); + for i in 0..prefix_stmts { + body.push(Stmt::Expr(Expr::Number(i as f64))); + } + body.push(Stmt::Return(Some(Expr::New { + class_name: "Cell".to_string(), + args: vec![Expr::Number(1.0)], + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }))); + Class { + id: 4, + name: "Factory".to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: Vec::new(), + constructor: None, + methods: vec![Function { + id: FACTORY_METHOD_ID, + name: "make".to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Named("Cell".to_string()), + body, + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }], + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + computed_members: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + } +} + +fn tiny_factory_module(prefix_stmts: usize) -> Module { + let mut module = Module::new("tiny_factory.ts"); + module.classes = vec![cell_class(), tiny_factory_class(prefix_stmts)]; + module.init_kind = ModuleInitKind::Eager; + module +} + +fn tiny_factory_budget_module(method_count: u32) -> Module { + let mut factory = tiny_factory_class(0); + let template = factory.methods.pop().expect("factory method template"); + factory.methods = (0..method_count) + .map(|offset| { + let mut method = template.clone(); + method.id = FACTORY_METHOD_ID + offset; + method.name = format!("make{offset}"); + method + }) + .collect(); + + let mut module = Module::new("tiny_factory_budget.ts"); + module.classes = vec![cell_class(), factory]; + module.init_kind = ModuleInitKind::Eager; + module +} + /// `function walk(n) { if (n > 0) walk(n - 1); return new Cell(n) }` — the /// recursive-descent shape, with NO loop anywhere and its entry call in /// straight-line module init. `recurse = false` drops the self-call, which is @@ -296,6 +377,51 @@ fn assert_inline_new_not_forced() { ); } +#[test] +fn a_tiny_allocation_method_inlines_its_bump_allocator() { + assert_inline_new_not_forced(); + let ir = ir_for(tiny_factory_module(0)); + assert!( + ir.contains(INLINE_SLOW_CALL) && ir.contains(INLINE_FAST_BLOCK), + "a one-statement allocation method is a bounded allocation kernel, but its `new` \ + took the outlined allocator:\n{ir}" + ); + assert!( + !ir.contains(STAMPED_OUTLINED_CALL), + "the tiny allocation method still emitted the outlined allocator:\n{ir}" + ); +} + +#[test] +fn a_non_tiny_allocation_method_keeps_the_outlined_allocator() { + assert_inline_new_not_forced(); + // Two prefix statements plus the return make this a three-statement + // method, just beyond the deliberately narrow tiny-method admission. + let ir = ir_for(tiny_factory_module(2)); + assert!( + ir.contains(STAMPED_OUTLINED_CALL), + "a three-statement method escaped the bounded tiny-method rule:\n{ir}" + ); + assert!( + !ir.contains(INLINE_FAST_BLOCK), + "the non-tiny method unexpectedly emitted an inline allocation site:\n{ir}" + ); +} + +#[test] +fn tiny_allocation_methods_over_the_module_budget_are_all_outlined() { + assert_inline_new_not_forced(); + let ir = ir_for(tiny_factory_budget_module(9)); + assert!( + ir.contains(STAMPED_OUTLINED_CALL), + "allocation methods over the eight-site budget emitted no outlined allocator:\n{ir}" + ); + assert!( + !ir.contains(INLINE_FAST_BLOCK), + "the all-or-none method budget admitted part of a nine-site module:\n{ir}" + ); +} + #[test] fn a_self_recursive_function_inlines_its_bump_allocator() { assert_inline_new_not_forced(); diff --git a/crates/perry-codegen/src/lower_call/method_override.rs b/crates/perry-codegen/src/lower_call/method_override.rs index 533fbeebfb..0f397ac7fb 100644 --- a/crates/perry-codegen/src/lower_call/method_override.rs +++ b/crates/perry-codegen/src/lower_call/method_override.rs @@ -582,6 +582,7 @@ pub(super) fn emit_guarded_direct_method_call( receiver_class_name: &str, property: &str, direct_fn: &str, + direct_call_fn: Option<&str>, direct_arg_slices: &[(crate::types::LlvmType, &str)], source_args: &[perry_hir::Expr], fallback_user_args: &[String], @@ -620,7 +621,8 @@ pub(super) fn emit_guarded_direct_method_call( // site (the #1787 static-receiver bug): those targets need // `js_class_static_method_call`, not a plain `call double`, and no // proven-`this` clone is ever emitted for them. - let pshape_fn: Option = (!direct_fn.starts_with("perry_static_") + let pshape_fn: Option = (direct_call_fn.is_none() + && !direct_fn.starts_with("perry_static_") && ctx .pshape_methods .contains_key(&(receiver_class_name.to_string(), property.to_string()))) @@ -630,6 +632,7 @@ pub(super) fn emit_guarded_direct_method_call( // are), so it is resolved once here rather than five times below. let generic_body_fn: String = pshape_fn .clone() + .or_else(|| direct_call_fn.map(str::to_string)) .unwrap_or_else(|| crate::codegen::generic_method_body_name(direct_fn)); let expected_class_id_str = expected_class_id.to_string(); @@ -1276,15 +1279,21 @@ pub(super) fn emit_guarded_direct_method_call( // body; a receiver miss is still handled by the outer dynamic // fallback. let pshape_arg_fallback = pshape_fn.as_deref().unwrap_or(direct_fn); - if let Some(argument_specialized) = emit_pshape_argument_dispatch( - ctx, - receiver_class_name, - property, - direct_fn, - pshape_arg_fallback, - direct_arg_slices, - source_args, - ) { + let argument_specialized = direct_call_fn + .is_none() + .then(|| { + emit_pshape_argument_dispatch( + ctx, + receiver_class_name, + property, + direct_fn, + pshape_arg_fallback, + direct_arg_slices, + source_args, + ) + }) + .flatten(); + if let Some(argument_specialized) = argument_specialized { argument_specialized } else { // Representation-selection Phase 5a: this arm is reached ONLY @@ -1315,7 +1324,8 @@ pub(super) fn emit_guarded_direct_method_call( // `perry_static_` exclusion and the declaring-class argument are // written out) is the same clone the typed arms above now route // their generic fallbacks to. - let target = nonnegative_index_direct_fn + let target = direct_call_fn + .or(nonnegative_index_direct_fn) .or(pshape_fn.as_deref()) .unwrap_or(direct_fn); let result = ctx.block().call(DOUBLE, target, direct_arg_slices); diff --git a/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs b/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs index 688bdba2a1..612244d226 100644 --- a/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs +++ b/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs @@ -222,6 +222,7 @@ fn build_direct_method_args( has_rest: bool, has_synthetic_arguments: bool, has_user_rest: bool, + arguments_length_only: bool, declared_count: usize, undefined_lit: &str, ) -> Vec { @@ -252,7 +253,10 @@ fn build_direct_method_args( if has_user_rest || !has_synthetic_arguments { bundles.push((fixed_user, false)); } - if has_synthetic_arguments { + if has_synthetic_arguments && arguments_length_only { + debug_assert!(!has_user_rest); + direct_args.push(double_literal(user_args.len() as f64)); + } else if has_synthetic_arguments { bundles.push((0, true)); } for (from, mark) in bundles { @@ -778,6 +782,7 @@ pub(crate) fn try_lower_instance_method_call( impl_has_rest, impl_has_synth, impl_has_user_rest, + false, impl_decl_count, &undefined_lit, ); @@ -1020,6 +1025,10 @@ pub(crate) fn try_lower_instance_method_call( // read off the body the fallback call reaches. let fallback_has_user_rest = crate::codegen::arguments::method_has_user_rest(ctx, &fallback_key.0, property); + let fallback_arguments_length_only = matches!( + ctx.method_arguments_length_only.get(&fallback_key), + Some(&true) + ); // Keep the maximum declared arity only for selecting safe // shape-guarded/typed fast-path arms below. Direct calls no longer // share an ABI vector: the fallback and each virtual override are @@ -1080,18 +1089,19 @@ pub(crate) fn try_lower_instance_method_call( )?)); } let undefined_lit = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); - let fallback_args = build_direct_method_args( + let direct_args = build_direct_method_args( ctx, &recv_box, &fallback_user_args, fallback_has_rest, fallback_has_synthetic_arguments, fallback_has_user_rest, + fallback_arguments_length_only, fallback_decl_count, &undefined_lit, ); let arg_slices: Vec<(crate::types::LlvmType, &str)> = - fallback_args.iter().map(|s| (DOUBLE, s.as_str())).collect(); + direct_args.iter().map(|s| (DOUBLE, s.as_str())).collect(); // Arms for the shape-guarded direct call: every class in // `class_name`'s subclass closure, paired with the body `property` @@ -1181,8 +1191,25 @@ pub(crate) fn try_lower_instance_method_call( if subclass_arms.len() > MAX_SUBCLASS_DISPATCH_ARMS { subclass_arms.clear(); } + // This ABI belongs to one producer-proved body. A subclass arm may + // resolve to a different implementation, so let it take the raw + // dynamic fallback instead of sharing the scalar-count vector. + if fallback_arguments_length_only { + subclass_arms.clear(); + } - if !fallback_has_rest { + // A synthesized `arguments` slot is rest-shaped in the HIR, but + // it does not prevent a same-class guarded direct call. The + // concrete ABI vector above already contains the correctly marked + // argument bundle, while the guard's miss path deliberately takes + // the original flat user arguments through + // `js_native_call_method_by_id` so an own override receives normal + // JavaScript call arguments. Keep genuine user-rest methods and + // all mixed user-rest + `arguments` methods on the established + // dynamic path; their direct-call specialization remains a + // separate ABI problem. + let synth_arguments_only = fallback_has_synthetic_arguments && !fallback_has_user_rest; + if !fallback_has_rest || synth_arguments_only { let typed_method_key = (class_name.clone(), property.to_string()); let typed_formal_count = ctx .method_param_counts @@ -1436,6 +1463,11 @@ pub(crate) fn try_lower_instance_method_call( .unwrap_or(false); if ptr_shape_receiver && !fallback_fn.starts_with("perry_static_") { ctx.note_ptr_shape_consumed(object, "ptr_shape_method"); + if fallback_arguments_length_only { + let target = + crate::codegen::arguments::arguments_length_method_name(&fallback_fn); + return Ok(Some(ctx.block().call(DOUBLE, &target, &arg_slices))); + } // Representation-selection Phase 5a: the proven-`this` // clone, when one was emitted. Hoisted above the // typed-receiver branch because BOTH exits of this block @@ -1558,21 +1590,48 @@ pub(crate) fn try_lower_instance_method_call( let direct = ctx.block().call(DOUBLE, generic_target, &arg_slices); return Ok(Some(direct)); } + let arguments_length_direct_fn = fallback_arguments_length_only + .then(|| crate::codegen::arguments::arguments_length_method_name(&fallback_fn)); if let Some(guarded) = emit_guarded_direct_method_call( ctx, &recv_box, &class_name, property, &fallback_fn, + arguments_length_direct_fn.as_deref(), &arg_slices, args, &fallback_user_args, - nonnegative_index_direct_name.as_deref(), - typed_direct, - typed_receiver_direct, - typed_i32_direct, - typed_i1_direct, - typed_string_direct, + if fallback_arguments_length_only { + None + } else { + nonnegative_index_direct_name.as_deref() + }, + if fallback_arguments_length_only { + None + } else { + typed_direct + }, + if fallback_arguments_length_only { + None + } else { + typed_receiver_direct + }, + if fallback_arguments_length_only { + None + } else { + typed_i32_direct + }, + if fallback_arguments_length_only { + None + } else { + typed_i1_direct + }, + if fallback_arguments_length_only { + None + } else { + typed_string_direct + }, shape_only_guard, &subclass_arms, ) { @@ -1580,6 +1639,28 @@ pub(crate) fn try_lower_instance_method_call( } } + // No direct/proven route consumed the scalar ABI. Materialize the + // marked public Arguments bundle only for the established + // own-override/static paths below. + drop(arg_slices); + let fallback_args = if fallback_arguments_length_only { + build_direct_method_args( + ctx, + &recv_box, + &fallback_user_args, + fallback_has_rest, + fallback_has_synthetic_arguments, + fallback_has_user_rest, + false, + fallback_decl_count, + &undefined_lit, + ) + } else { + direct_args + }; + let arg_slices: Vec<(crate::types::LlvmType, &str)> = + fallback_args.iter().map(|s| (DOUBLE, s.as_str())).collect(); + if overrides.is_empty() { // Issue #620: before falling through to the static method, // check whether the receiver has an own-property override @@ -1712,6 +1793,7 @@ pub(crate) fn try_lower_instance_method_call( has_rest, has_synthetic_arguments, has_user_rest, + false, declared_count, &undefined_lit, ); diff --git a/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs b/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs index 3b86b99302..085b43639f 100644 --- a/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs +++ b/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs @@ -482,6 +482,7 @@ fn imported_remote() -> ImportedClass { method_param_counts: vec![0], method_has_rest: vec![false], method_has_synthetic_arguments: vec![false], + method_arguments_length_only: vec![false], static_field_names: Vec::new(), static_method_names: Vec::new(), static_method_return_types: Vec::new(), @@ -569,3 +570,56 @@ fn imported_pointer_layout_does_not_invent_a_consumer_typed_shape_id() { "the imported layout must be validated after its real constructor, not declared before it:\n{ir}" ); } + +#[test] +fn imported_length_only_arguments_capability_uses_scalar_direct_abi() { + let mut module = Module::new("imported_arguments_length_consumer.ts"); + module.init = vec![ + Stmt::Let { + id: 20, + name: "instance".to_string(), + ty: Type::Named("Remote".to_string()), + mutable: false, + init: Some(Expr::New { + class_name: "Remote".to_string(), + args: vec![Expr::Null], + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }), + }, + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(20)), + property: "read".to_string(), + byte_offset: 0, + }), + args: vec![Expr::Integer(1), Expr::Integer(2)], + type_args: Vec::new(), + byte_offset: 0, + }), + ]; + + let mut remote = imported_remote(); + remote.method_param_counts = vec![1]; + remote.method_has_rest = vec![true]; + remote.method_has_synthetic_arguments = vec![true]; + remote.method_arguments_length_only = vec![true]; + let mut opts = ir_opts(); + opts.imported_classes.push(remote); + + let ir = + String::from_utf8(compile_module(&module, opts).unwrap()).expect("LLVM IR should be UTF-8"); + assert!( + ir.contains("declare double @perry_method_producer_ts__Remote__read$arguments_length") + && ir.contains("call double @perry_method_producer_ts__Remote__read$arguments_length",) + && ir.contains("double 2.0"), + "the consumer should trust the producer capability and pass only the actual count:\n{ir}" + ); + assert!( + !ir.contains("call i64 @js_array_alloc") + && !ir.contains("call i64 @js_array_push_f64") + && !ir.contains("call i64 @js_array_mark_arguments_object"), + "the imported direct path should not allocate an argument bundle:\n{ir}" + ); +} diff --git a/crates/perry-codegen/src/runtime_decls/strings_part2.rs b/crates/perry-codegen/src/runtime_decls/strings_part2.rs index 406d442753..ed8270ef4e 100644 --- a/crates/perry-codegen/src/runtime_decls/strings_part2.rs +++ b/crates/perry-codegen/src/runtime_decls/strings_part2.rs @@ -667,6 +667,7 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) { module.declare_function("js_array_find_last", DOUBLE, &[I64, I64]); module.declare_function("js_array_find_last_index", I32, &[I64, I64]); module.declare_function("js_array_some", DOUBLE, &[I64, I64]); + module.declare_function("js_array_some_captureless", DOUBLE, &[I64, PTR]); module.declare_function("js_array_every", DOUBLE, &[I64, I64]); // Phase E: async/await runtime support. diff --git a/crates/perry-codegen/tests/typed_feedback.rs b/crates/perry-codegen/tests/typed_feedback.rs index fadd9e71c7..364fd02870 100644 --- a/crates/perry-codegen/tests/typed_feedback.rs +++ b/crates/perry-codegen/tests/typed_feedback.rs @@ -1,6 +1,9 @@ use perry_codegen::{compile_module, AppMetadata, CompileOptions}; use perry_hir::types::{FunctionType, Type}; -use perry_hir::{BinaryOp, Class, ClassField, Expr, Function, Module, ModuleInitKind, Param, Stmt}; +use perry_hir::{ + ArgumentsObjectMeta, BinaryOp, Class, ClassField, Expr, Function, Module, ModuleInitKind, + Param, Stmt, +}; /// Serializes env-mutating tests so a concurrent test never observes a /// half-applied variable. Mirrors the guard in `typed_shape_descriptors.rs`. @@ -837,6 +840,132 @@ fn typed_feedback_guards_direct_class_method_specialization() { assert!(ir.contains("call double @js_native_call_method")); } +#[test] +fn synthetic_arguments_only_method_uses_shape_guarded_direct_call() { + let mut counter = class(104, "Counter", Vec::new()); + counter.methods.push(Function { + id: 8, + name: "count".to_string(), + type_params: Vec::new(), + params: vec![ + param(2, "value", Type::Any), + Param { + id: 3, + name: "arguments".to_string(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: true, + arguments_object: Some(ArgumentsObjectMeta { + strict: false, + simple_parameters: true, + mapped_parameter_ids: Vec::new(), + restricted_callee: false, + }), + }, + ], + return_type: Type::Number, + body: vec![Stmt::Return(Some(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(3)), + property: "length".to_string(), + byte_offset: 0, + }))], + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }); + counter.methods.push(Function { + id: 9, + name: "identity".to_string(), + type_params: Vec::new(), + params: vec![Param { + id: 4, + name: "arguments".to_string(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: true, + arguments_object: Some(ArgumentsObjectMeta { + strict: false, + simple_parameters: true, + mapped_parameter_ids: Vec::new(), + restricted_callee: false, + }), + }], + return_type: Type::Any, + body: vec![Stmt::Return(Some(Expr::LocalGet(4)))], + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }); + let ir = ir_for(module_with_classes( + "synthetic_arguments_method_guard.ts", + vec![counter], + vec![param(1, "counter", Type::Named("Counter".to_string()))], + Type::Number, + vec![Stmt::Return(Some(Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(1)), + property: "count".to_string(), + byte_offset: 0, + }), + args: vec![Expr::Number(7.0)], + type_args: Vec::new(), + byte_offset: 0, + }))], + )); + let probe_start = ir + .find("define double @perry_fn_synthetic_arguments_method_guard_ts__probe") + .expect("probe should be emitted"); + let probe_tail = &ir[probe_start..]; + let probe_end = probe_tail + .find("\n}\n") + .expect("probe should have a complete definition"); + let probe_ir = &probe_tail[..probe_end]; + + assert!( + probe_ir.contains("method_direct.inline_deref") + && probe_ir.contains("method_direct.fast") + && probe_ir.contains("method_direct.fallback"), + "synthetic-arguments-only methods should use the exact-shape direct guard:\n{probe_ir}" + ); + assert!( + probe_ir.contains( + "call double @perry_method_synthetic_arguments_method_guard_ts__Counter__count$arguments_length", + ) && probe_ir.contains("double 1.0"), + "the guarded direct arm should pass the actual argument count to the additive clone:\n{probe_ir}" + ); + assert!( + !probe_ir.contains("call i64 @js_array_alloc") + && !probe_ir.contains("call i64 @js_array_push_f64") + && !probe_ir.contains("call i64 @js_array_mark_arguments_object"), + "a length-only direct call must not allocate or fill an argument bundle:\n{probe_ir}" + ); + assert!( + probe_ir.contains("call double @js_native_call_method_by_id") + && !probe_ir.contains("call double @js_object_get_own_field_or_undef"), + "a guard miss should preserve dynamic override dispatch without an eager own-property scan:\n{probe_ir}" + ); + assert!( + ir.contains( + "define double @perry_method_synthetic_arguments_method_guard_ts__Counter__count$arguments_length", + ) && !ir.contains( + "perry_method_synthetic_arguments_method_guard_ts__Counter__identity$arguments_length", + ), + "only the producer-proved length-only method may publish the scalar ABI:\n{ir}" + ); +} + #[test] fn typed_feedback_guards_direct_closure_call_specialization() { let closure_ty = Type::Function(FunctionType { diff --git a/crates/perry-hir/src/lower/collection_view_tests.rs b/crates/perry-hir/src/lower/collection_view_tests.rs index e730a92a13..b0ba93e194 100644 --- a/crates/perry-hir/src/lower/collection_view_tests.rs +++ b/crates/perry-hir/src/lower/collection_view_tests.rs @@ -14,7 +14,8 @@ #![cfg(test)] -use crate::Module; +use crate::types::Type; +use crate::{Module, Stmt}; use perry_diagnostics::SourceCache; fn lower(src: &str) -> Module { @@ -234,3 +235,83 @@ fn the_route_probe_actually_discriminates() { "route probe cannot tell the two lowerings apart" ); } + +/// Function and method bodies use a separate statement lowerer from module +/// initializers. Keep the Map fast path's K/V types when pre-defining the +/// destructured bindings there too: later expressions are lowered before the +/// synthetic binding statements are emitted, so an `Any` placeholder turns a +/// statically-known `Command[]#some` into dynamic property lookup and native +/// method dispatch for the whole lifetime of the loop body. +#[test] +fn method_map_destructuring_preserves_key_and_value_types() { + fn binding_type(stmts: &[Stmt], wanted: &str) -> Option { + for stmt in stmts { + if let Stmt::Let { name, ty, .. } = stmt { + if name == wanted { + return Some(ty.clone()); + } + } + let found = match stmt { + Stmt::If { + then_branch, + else_branch, + .. + } => binding_type(then_branch, wanted) + .or_else(|| else_branch.as_deref().and_then(|s| binding_type(s, wanted))), + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => binding_type(body, wanted), + Stmt::For { init, body, .. } => init + .as_deref() + .and_then(|s| binding_type(std::slice::from_ref(s), wanted)) + .or_else(|| binding_type(body, wanted)), + Stmt::Try { + body, + catch, + finally, + } => binding_type(body, wanted) + .or_else(|| catch.as_ref().and_then(|c| binding_type(&c.body, wanted))) + .or_else(|| finally.as_deref().and_then(|s| binding_type(s, wanted))), + Stmt::Switch { cases, .. } => cases + .iter() + .find_map(|case| binding_type(&case.body, wanted)), + Stmt::Labeled { body, .. } => { + binding_type(std::slice::from_ref(body.as_ref()), wanted) + } + _ => None, + }; + if found.is_some() { + return found; + } + } + None + } + + let module = lower( + r#" + class Buffer { + execute(entityCommands: Map) { + for (const [entityId, commands] of entityCommands) { + if (commands.some((command) => command === "destroy")) { + console.log(entityId); + } + } + } + } + "#, + ); + let method = module + .classes + .iter() + .find(|class| class.name == "Buffer") + .and_then(|class| class.methods.iter().find(|method| method.name == "execute")) + .expect("Buffer.execute should be lowered"); + + assert_eq!(binding_type(&method.body, "entityId"), Some(Type::Number)); + assert_eq!( + binding_type(&method.body, "commands"), + Some(Type::Array(Box::new(Type::String))) + ); + assert!( + format!("{method:?}").contains("ArraySome"), + "typed commands.some should use the specialized ArraySome HIR" + ); +} diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index 74ee48cbd1..5e73f3bf13 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -1421,6 +1421,31 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result variants.iter().any(type_contains_map), _ => false, }; + // Preserve Map's generic arguments through the separate + // function/method-body for-of lowerer. The loop body is lowered + // after its bindings are pre-defined, so using `Any` there makes + // every operation in the body permanently dynamic even though the + // entry reads themselves retain enough information to infer K/V. + // Mirrors the module-init lowerer in `lower/stmt_loops.rs`, + // including its narrowed `Map | undefined` support. + let map_type_args: Option> = if is_iterable_map { + match &iterable_type { + Some(Type::Generic { base, type_args }) if base == "Map" => { + Some(type_args.clone()) + } + Some(Type::Union(variants)) => { + variants.iter().find_map(|variant| match variant { + Type::Generic { base, type_args } if base == "Map" => { + Some(type_args.clone()) + } + _ => None, + }) + } + _ => None, + } + } else { + None + }; // The head shapes the index fast path accepts — and why the // single-ident one is a correctness fix, not just a faster route — // live in `for_head::map_index_fast_path_head`. @@ -1539,45 +1564,46 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result so the loop variable gets the right type. - let inferred_elem_type: Option = match &iterable_type { - Some(Type::Array(elem)) => Some((**elem).clone()), - Some(Type::Generic { base, type_args }) - if base == "Array" && type_args.len() == 1 => - { - Some(type_args[0].clone()) - } - Some(Type::Generic { base, type_args }) - if base == "Map" && type_args.len() >= 2 => - { + let inferred_elem_type: Option = map_type_args + .as_ref() + .filter(|type_args| type_args.len() >= 2) + .map(|type_args| { // for-of over Map yields [K, V] tuples - Some(Type::Tuple(vec![ - type_args[0].clone(), - type_args[1].clone(), - ])) - } - Some(Type::Generic { base, type_args }) - if base == "Set" && !type_args.is_empty() => - { - Some(type_args[0].clone()) - } - _ => None, - }; + Type::Tuple(vec![type_args[0].clone(), type_args[1].clone()]) + }) + .or_else(|| match &iterable_type { + Some(Type::Array(elem)) => Some((**elem).clone()), + Some(Type::Generic { base, type_args }) + if base == "Array" && type_args.len() == 1 => + { + Some(type_args[0].clone()) + } + Some(Type::Generic { base, type_args }) + if base == "Set" && !type_args.is_empty() => + { + Some(type_args[0].clone()) + } + _ => None, + }); // For the Map fast path the holder must be typed Map so // `__m.size` resolves through `is_map_expr` to `js_map_size`. let holder_type = if is_string_iter { Type::String } else if map_kv_fastpath { - if let Some(Type::Generic { base, type_args }) = iterable_type.clone() { - if base == "Map" && type_args.len() >= 2 { - Type::Generic { - base: "Map".to_string(), - type_args, - } - } else { - Type::Any - } - } else { - Type::Any + Type::Generic { + base: "Map".to_string(), + type_args: vec![ + map_type_args + .as_ref() + .and_then(|types| types.first()) + .cloned() + .unwrap_or(Type::Any), + map_type_args + .as_ref() + .and_then(|types| types.get(1)) + .cloned() + .unwrap_or(Type::Any), + ], } } else if set_fastpath { // Holder typed as Set so `__s.size` resolves through @@ -1669,12 +1695,17 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result Result Option f64 { crate::value::js_nanbox_pointer(obj as i64) } -/// `const rows = []; rows.push(new C())` — the construction shape the measured -/// kernel uses, and the one `note_element_store` establishes from. +/// `const rows = []; rows.push(new C())` followed by the proof request an +/// optimized loop emits in its preheader. fn shaped(count: usize) -> *mut ArrayHeader { let mut arr = js_array_alloc(count as u32); for _ in 0..count { arr = js_array_push_f64(arr, instance(CLASS_A)); } assert!( - unsafe { element_shape_proof(arr) }.is_some(), + unsafe { ensure_element_shape(arr) }.is_some(), "fixture must start proven, or every verdict below is vacuous" ); arr @@ -394,16 +394,12 @@ fn matrix_length_truncate_and_extend_revoke() { // --------------------------------------------------------------------------- // BUILDERS — the "rebuild regains it" half of self-healing // -// Array *builders* split across the two funnels (audited for #7480): the -// per-element ones (`JSON.parse`, the #7539 tape materialiser, most of the -// `Array.from` family, the concat fallback) reach `layout_note_slot` and so -// **establish** as they fill; the bulk-copy ones (dense spread, the concat fast -// path, `Array.from` on a jsvalue) `ptr::copy` and then `rebuild_array_layout_ -// exact`, which leaves the result deliberately **unproven**. -// -// Both are correct — the rule is that a builder may leave a result unproven, -// but must never leave it proven at the WRONG class. These tests pin exactly -// that, and pin that the unproven case heals on the first `ensure`. +// Array builders deliberately leave their result unproven. Creating and +// maintaining a side-table proof during every per-element fill taxes programs +// that never emit an element-shape consumer. Bulk-copy builders already had +// this lifecycle; per-element builders now match it. The rule is that a +// builder starts unproven, proves on the first `ensure`, and must never report +// the wrong class. // --------------------------------------------------------------------------- /// `[...arr]` — the bulk-copy builder. The clone is a fresh allocation that @@ -458,18 +454,20 @@ fn matrix_a_revoked_array_regains_the_invariant_when_rebuilt() { ); // Rebuild it the way user code does — every element replaced by a shaped - // instance — and the invariant comes back on its own. + // instance. It remains unproven until a consumer asks, then self-heals. let mut rebuilt = js_array_alloc(4); for _ in 0..4 { rebuilt = js_array_push_f64(rebuilt, instance(CLASS_A)); } - let healed = proof(rebuilt).expect("a rebuilt homogeneous array is proven again"); + assert!(proof(rebuilt).is_none()); + let healed = unsafe { ensure_element_shape(rebuilt) } + .expect("a rebuilt homogeneous array proves on demand"); assert_eq!(healed.class_id, CLASS_A); assert_eq!(healed.verified_len, 4); } -/// `Array.from`-family builder that fills per element: it reaches -/// `layout_note_slot`, so a homogeneous source establishes as it fills. +/// `Array.from`-family builder that fills per element: it stays unproven until +/// a generated consumer requests its shape. #[test] fn matrix_from_values_builder_establishes_or_heals_but_never_lies() { let _serialized = test_serialize(); diff --git a/crates/perry-runtime/src/array/element_shape_tests.rs b/crates/perry-runtime/src/array/element_shape_tests.rs index e8675109d5..46651b79d1 100644 --- a/crates/perry-runtime/src/array/element_shape_tests.rs +++ b/crates/perry-runtime/src/array/element_shape_tests.rs @@ -35,14 +35,20 @@ fn push(arr: *mut ArrayHeader, value: f64) -> *mut ArrayHeader { js_array_push_f64(arr, value) } -/// `const rows = []; rows.push(new C())` — the construction shape the -/// compile-time collector already admits (#7034 E1/E2), and the one the -/// measured kernel uses. +/// Build the construction shape admitted by the compile-time collector, then +/// request the proof its generated preheader would consume. Most tests below +/// need a proven fixture; the demand-driven lifecycle itself has a dedicated +/// test. fn built_from_pushes(class_id: u32, count: usize) -> *mut ArrayHeader { let mut arr = js_array_alloc(count as u32); for _ in 0..count { arr = push(arr, instance(class_id)); } + if count != 0 { + let established = unsafe { ensure_element_shape(arr) } + .expect("the homogeneous fixture must prove on demand"); + assert_eq!(established.class_id, class_id); + } arr } @@ -55,12 +61,23 @@ fn proof(arr: *mut ArrayHeader) -> Option { // --------------------------------------------------------------------------- #[test] -fn first_push_of_a_shaped_object_into_an_empty_array_sets_the_invariant() { +fn pushes_do_not_create_an_unrequested_element_shape_proof() { let _serialized = test_serialize(); - let arr = built_from_pushes(CLASS_A, 1); - let proof = proof(arr).expect("first shaped push must establish the invariant"); - assert_eq!(proof.class_id, CLASS_A); - assert_eq!(proof.verified_len, 1); + let mut arr = js_array_alloc(8); + for _ in 0..8 { + arr = push(arr, instance(CLASS_A)); + } + assert!( + proof(arr).is_none(), + "stores must not create an unused proof" + ); + unsafe { assert!(!test_element_shape_bit_set(arr)) }; + assert!(!test_element_shape_record_exists(arr as usize)); + + let requested = unsafe { ensure_element_shape(arr) } + .expect("a consumer must still be able to prove the homogeneous array"); + assert_eq!(requested.class_id, CLASS_A); + assert_eq!(requested.verified_len, 8); unsafe { assert!(test_element_shape_bit_set(arr)) }; assert!(test_element_shape_record_exists(arr as usize)); } @@ -68,7 +85,10 @@ fn first_push_of_a_shaped_object_into_an_empty_array_sets_the_invariant() { #[test] fn matching_pushes_extend_the_verified_prefix() { let _serialized = test_serialize(); - let arr = built_from_pushes(CLASS_A, 8); + let mut arr = built_from_pushes(CLASS_A, 1); + for _ in 1..8 { + arr = push(arr, instance(CLASS_A)); + } let proof = proof(arr).expect("homogeneous pushes must keep the invariant"); assert_eq!(proof.class_id, CLASS_A); assert_eq!(proof.verified_len, 8); diff --git a/crates/perry-runtime/src/array/header.rs b/crates/perry-runtime/src/array/header.rs index c825ff276b..513dcb7ec6 100644 --- a/crates/perry-runtime/src/array/header.rs +++ b/crates/perry-runtime/src/array/header.rs @@ -1077,17 +1077,8 @@ pub(crate) unsafe fn canonicalize_array_numeric_store_bits( value_bits } -#[inline] -pub(crate) unsafe fn canonicalize_array_numeric_store_value( - arr: *mut ArrayHeader, - value: f64, -) -> f64 { - f64::from_bits(canonicalize_array_numeric_store_bits(arr, value.to_bits())) -} - /// Canonicalize a store using the flag word read from an already-resolved -/// array. This is the no-second-classification twin of -/// [`canonicalize_array_numeric_store_value`]. +/// array, avoiding a second ownership/forwarding classification. #[inline(always)] pub(crate) fn canonicalize_array_numeric_store_value_from_flags(flags: u16, value: f64) -> f64 { let raw_layout = crate::gc::GC_ARRAY_RAW_F64_LAYOUT | crate::gc::GC_ARRAY_RAW_F64_HOLES; diff --git a/crates/perry-runtime/src/array/header_gc_slots.rs b/crates/perry-runtime/src/array/header_gc_slots.rs index 14b1c79ee0..1e46cbd7de 100644 --- a/crates/perry-runtime/src/array/header_gc_slots.rs +++ b/crates/perry-runtime/src/array/header_gc_slots.rs @@ -34,6 +34,40 @@ pub(crate) unsafe fn note_array_slot(arr: *mut ArrayHeader, index: usize, value_ crate::gc::runtime_write_barrier_slot(arr as usize, slot, value_bits); } +/// Store and record one element on an array whose live head and header flags +/// the caller has already resolved. +/// +/// The ordinary [`note_array_slot`] entry point must rediscover the numeric +/// layout through `clean_arr_ptr`. Hot array writers have already paid that +/// ownership/forwarding proof and have the same header word in hand for their +/// frozen/descriptor checks. Reusing it here avoids reclassifying the same +/// pointer while preserving the numeric-layout note, GC layout note, and write +/// barrier as one indivisible store protocol. +/// +/// # Safety +/// +/// `arr` must be the non-null result of `clean_arr_ptr_mut`, and `flags` must +/// have been read from that exact live head with no intervening Perry +/// allocation or safepoint. +#[inline] +pub(crate) unsafe fn store_array_slot_resolved( + arr: *mut ArrayHeader, + index: usize, + value: f64, + flags: u16, +) -> u64 { + let value = canonicalize_array_numeric_store_value_from_flags(flags, value); + let value_bits = value.to_bits(); + // GC_STORE_AUDIT(BARRIERED): the layout note and runtime_write_barrier_slot + // below cover this resolved-head slot write. + std::ptr::write(array_elements_ptr(arr).add(index), value_bits); + note_array_numeric_index_write(arr, index, value_bits); + crate::gc::layout_note_slot(arr as usize, index, value_bits); + let slot = array_elements_ptr(arr).add(index) as usize; + crate::gc::runtime_write_barrier_slot(arr as usize, slot, value_bits); + value_bits +} + #[inline] pub(crate) unsafe fn note_array_slot_layout_only( arr: *mut ArrayHeader, diff --git a/crates/perry-runtime/src/array/indexing.rs b/crates/perry-runtime/src/array/indexing.rs index 04ae28e988..bf86f40666 100644 --- a/crates/perry-runtime/src/array/indexing.rs +++ b/crates/perry-runtime/src/array/indexing.rs @@ -1,5 +1,4 @@ //! Indexing — length / element get / element set / hybrid string-or-index dispatch. -use super::header::{array_numeric_layout, NumericArrayLayout}; use super::*; use std::ptr; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; @@ -226,7 +225,16 @@ pub(crate) fn array_iteration_is_exotic(arr: *const ArrayHeader) -> bool { if arr.is_null() { return false; } - if array_object_flags(arr) & crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS != 0 { + if crate::buffer::is_registered_buffer(arr as usize) + || crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() + { + return true; + } + // SAFETY: the clean above resolved this exact live head, and the flag read + // precedes every operation that could allocate or safepoint. The + // compatible header-less receivers exited above. + let flags = unsafe { array_object_flags_resolved(arr) }; + if flags & crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS != 0 { return true; } if ARRAY_PROTO_HAS_INDEX.load(Ordering::Relaxed) { @@ -761,7 +769,10 @@ pub extern "C" fn js_array_get_f64_unchecked(arr: *const ArrayHeader, index: u32 let arr = cleaned; // Index accessors / custom attrs installed via `Object.defineProperty` // need the descriptor-aware getter. - if array_object_flags(arr) & crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS != 0 { + // SAFETY: `clean_arr_ptr` returned this live head and no safepoint has + // intervened. + let flags = unsafe { array_object_flags_resolved(arr) }; + if flags & crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS != 0 { return js_array_get_f64(arr, index); } const TAG_UNDEFINED_F64: f64 = f64::from_bits(0x7FFC_0000_0000_0001u64); @@ -802,9 +813,12 @@ pub extern "C" fn js_array_numeric_get_f64_unboxed(arr: *mut ArrayHeader, index: // proved this receiver is a non-forwarded plain Array with raw numeric // layout, so keep the helper leaf-small: avoid re-running the expensive // rebuild/descriptor path on every indexed read in numeric loops. + // SAFETY: the clean above resolved this exact live head and no safepoint + // has intervened. + let flags = unsafe { array_object_flags_resolved(arr) }; unsafe { - if array_numeric_layout(arr) == Some(NumericArrayLayout::RawF64) - && array_object_flags(arr) & crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS == 0 + if flags & crate::gc::GC_ARRAY_RAW_F64_LAYOUT != 0 + && flags & crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS == 0 && index < (*arr).length { let elements_ptr = @@ -1015,11 +1029,14 @@ pub extern "C" fn js_array_set_f64_unchecked(arr: *mut ArrayHeader, index: u32, if arr.is_null() { return; } - if array_is_frozen(arr) { + // SAFETY: the clean above resolved this exact live head and no safepoint + // has intervened. + let flags = unsafe { array_object_flags_resolved(arr) }; + if flags & crate::gc::OBJ_FLAG_FROZEN != 0 { return; } // Index accessors / non-writable attrs need the descriptor-aware setter. - if array_object_flags(arr) & crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS != 0 { + if flags & crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS != 0 { js_array_set_f64_extend(arr, index, value); return; } @@ -1032,12 +1049,9 @@ pub extern "C" fn js_array_set_f64_unchecked(arr: *mut ArrayHeader, index: u32, array_sparse_index_property_set(arr, index, value); return; } - let value = canonicalize_array_numeric_store_value(arr, value); - let value_bits = value.to_bits(); - let elements_ptr = (arr as *mut u8).add(std::mem::size_of::()) as *mut f64; - // GC_STORE_AUDIT(BARRIERED): unchecked array set is immediately recorded via note_array_slot. - ptr::write(elements_ptr.add(index as usize), value); - note_array_slot(arr, index as usize, value_bits); + // GC_STORE_AUDIT(BARRIERED): the resolved store performs the layout + // note and write barrier as part of the slot write. + store_array_slot_resolved(arr, index as usize, value, flags); } } @@ -1052,7 +1066,9 @@ pub extern "C" fn js_array_numeric_set_f64_unboxed( return 0; } - let flags = array_object_flags(arr); + // SAFETY: the clean above resolved this exact live head and no safepoint + // has intervened. + let flags = unsafe { array_object_flags_resolved(arr) }; if flags & (crate::gc::OBJ_FLAG_FROZEN | crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS) != 0 { return 0; } @@ -1063,7 +1079,7 @@ pub extern "C" fn js_array_numeric_set_f64_unboxed( // the whole layout on every iteration. Preserve the helper fallback for // direct runtime calls and arrays that have not been converted yet. unsafe { - if index < (*arr).length && array_numeric_layout(arr) == Some(NumericArrayLayout::RawF64) { + if index < (*arr).length && flags & crate::gc::GC_ARRAY_RAW_F64_LAYOUT != 0 { let Some(number) = value_bits_to_number(value.to_bits()) else { clear_array_numeric_layout(arr); return 0; @@ -1124,7 +1140,10 @@ pub extern "C" fn js_array_set_f64(arr: *mut ArrayHeader, index: u32, value: f64 ); return; } - if array_is_frozen(arr) { + // SAFETY: the clean above resolved this exact plain-array head; the + // Buffer/TypedArray exits precede this direct header read. + let flags = unsafe { array_object_flags_resolved(arr) }; + if flags & crate::gc::OBJ_FLAG_FROZEN != 0 { return; } unsafe { @@ -1136,12 +1155,9 @@ pub extern "C" fn js_array_set_f64(arr: *mut ArrayHeader, index: u32, value: f64 array_sparse_index_property_set(arr, index, value); return; } - let value = canonicalize_array_numeric_store_value(arr, value); - let value_bits = value.to_bits(); - let elements_ptr = (arr as *mut u8).add(std::mem::size_of::()) as *mut f64; - // GC_STORE_AUDIT(BARRIERED): array set is immediately recorded via note_array_slot. - ptr::write(elements_ptr.add(index as usize), value); - note_array_slot(arr, index as usize, value_bits); + // GC_STORE_AUDIT(BARRIERED): the resolved store performs the layout + // note and write barrier as part of the slot write. + store_array_slot_resolved(arr, index as usize, value, flags); } } @@ -1174,7 +1190,17 @@ pub(crate) fn array_strict_index_write_guard(arr: *mut ArrayHeader, index: u32) { return; } - let flags = array_object_flags(clean); + // SAFETY: `clean_arr_ptr_mut` returned this live plain-array head and the + // registry exits above exclude the compatible header-less receivers. + let flags = unsafe { array_object_flags_resolved(clean) }; + array_strict_index_write_guard_resolved(clean, index, flags); +} + +/// Strict element-write policy check for a live plain array whose header word +/// the caller already owns. This contains no Perry allocation or safepoint, so +/// the same resolved pointer and flags remain valid for the following store. +#[inline] +fn array_strict_index_write_guard_resolved(clean: *mut ArrayHeader, index: u32, flags: u16) { let length = unsafe { (*clean).length }; // A descriptor-bearing array is rare, so keep all key construction and @@ -1234,8 +1260,25 @@ pub extern "C" fn js_array_set_f64_extend_strict( index: u32, value: f64, ) -> *mut ArrayHeader { - array_strict_index_write_guard(arr, index); - js_array_set_f64_extend(arr, index, value) + let clean = clean_arr_ptr_mut(arr); + if clean.is_null() + || crate::buffer::is_registered_buffer(clean as usize) + || crate::typedarray::lookup_typed_array_kind(clean as usize).is_some() + { + // Preserve the existing polymorphic/subclass behavior on receivers + // that are not live plain arrays. These are cold and cannot use the + // resolved-header contract below. + array_strict_index_write_guard(arr, index); + return js_array_set_f64_extend(arr, index, value); + } + + // SAFETY: the clean above resolved this exact live plain-array head. The + // guard performs no Perry allocation/safepoint, so the proof remains live + // for the store core. + let flags = unsafe { array_object_flags_resolved(clean) }; + array_strict_index_write_guard_resolved(clean, index, flags); + crate::string::js_string_addref_if_heap_string(value); + unsafe { js_array_set_f64_extend_resolved(clean, index, value, flags) } } /// Set an element in an array by index, extending the array if needed @@ -1263,10 +1306,6 @@ pub extern "C" fn js_array_set_f64_extend( return js_array_alloc(0); } let arr = cleaned; - // If this write targets `Array.prototype`, mark the prototype as carrying an - // indexed property so out-of-bounds element reads on ordinary arrays consult - // it (ECMA-262 OrdinaryGet → prototype chain). Cheap no-op otherwise. - note_array_index_write(arr as usize); // Check if this is actually a buffer (Uint8Array) — write individual bytes if crate::buffer::is_registered_buffer(arr as usize) { crate::buffer::js_buffer_set( @@ -1285,7 +1324,29 @@ pub extern "C" fn js_array_set_f64_extend( ); return arr; } - let flags = array_object_flags(arr); + // SAFETY: the clean above resolved this live plain-array head, and the + // compatible Buffer/TypedArray receivers have exited. + let flags = unsafe { array_object_flags_resolved(arr) }; + unsafe { js_array_set_f64_extend_resolved(arr, index, value, flags) } +} + +/// Plain-array body of [`js_array_set_f64_extend`], entered after one shared +/// ownership/forwarding proof. `flags` is the header word for `arr`. +/// +/// # Safety +/// +/// `arr` and `flags` must satisfy [`array_object_flags_resolved`]'s contract. +#[inline] +unsafe fn js_array_set_f64_extend_resolved( + arr: *mut ArrayHeader, + index: u32, + value: f64, + flags: u16, +) -> *mut ArrayHeader { + // If this write targets `Array.prototype`, mark the prototype as carrying an + // indexed property so out-of-bounds element reads on ordinary arrays consult + // it (ECMA-262 OrdinaryGet → prototype chain). Cheap no-op otherwise. + note_array_index_write(arr as usize); let is_frozen = flags & crate::gc::OBJ_FLAG_FROZEN != 0; let blocks_extension = flags & (crate::gc::OBJ_FLAG_SEALED | crate::gc::OBJ_FLAG_NO_EXTEND) != 0; @@ -1341,12 +1402,9 @@ pub extern "C" fn js_array_set_f64_extend( array_sparse_index_property_set(arr, index, value); return arr; } - let value = canonicalize_array_numeric_store_value(arr, value); - let value_bits = value.to_bits(); - let elements_ptr = (arr as *mut u8).add(std::mem::size_of::()) as *mut f64; - // GC_STORE_AUDIT(BARRIERED): in-bounds extending set is immediately recorded via note_array_slot. - ptr::write(elements_ptr.add(index as usize), value); - note_array_slot(arr, index as usize, value_bits); + // GC_STORE_AUDIT(BARRIERED): the resolved store performs the + // layout note and write barrier as part of the slot write. + store_array_slot_resolved(arr, index as usize, value, flags); return arr; } @@ -1387,18 +1445,18 @@ pub extern "C" fn js_array_set_f64_extend( // afterwards: record it (dense drops to holes) instead of demoting // to the permanent O(n) verify walk. let had_raw_layout = crate::array::header::array_has_raw_f64_layout_or_holes(arr); - let elements_ptr = (arr as *mut u8).add(std::mem::size_of::()) as *mut f64; for i in length..index { // GC_STORE_AUDIT(BARRIERED): sparse gap sentinel is layout-noted + barriered by the hole-aware note. crate::array::header::note_array_hole_fill_slot(arr, i as usize); } // Set the value - let value = canonicalize_array_numeric_store_value(arr, value); - let value_bits = value.to_bits(); - // GC_STORE_AUDIT(BARRIERED): extending set value is immediately recorded via note_array_slot. - ptr::write(elements_ptr.add(index as usize), value); - note_array_slot(arr, index as usize, value_bits); + // `js_array_grow` may have replaced the backing allocation, so refresh + // the header word from the returned live head before canonicalizing. + let store_flags = array_object_flags_resolved(arr); + // GC_STORE_AUDIT(BARRIERED): the resolved store performs the layout + // note and write barrier as part of the slot write. + let value_bits = store_array_slot_resolved(arr, index as usize, value, store_flags); (*arr).length = new_length; if had_raw_layout && index > length @@ -1411,110 +1469,6 @@ pub extern "C" fn js_array_set_f64_extend( } } -/// Try to perform `arr[i] = arr[i] + delta` over a dense numeric window. -/// -/// This is intentionally transactional: the first pass validates the actual -/// runtime receiver and every source slot, and only then does the second pass -/// mutate. Returning `-1` means "run the ordinary JS loop"; no slot has been -/// changed in that case. A non-negative return is the counter value the source -/// loop would have on exit. -fn array_numeric_range_add_impl(receiver: f64, start: f64, end: Option, delta: f64) -> i64 { - let receiver_value = crate::value::JSValue::from_bits(receiver.to_bits()); - if !receiver_value.is_pointer() { - return -1; - } - let raw = receiver_value.as_pointer::() as usize; - let Some(header) = (unsafe { crate::value::addr_class::try_read_gc_header(raw) }) else { - return -1; - }; - if header.obj_type != crate::gc::GC_TYPE_ARRAY { - return -1; - } - let arr = clean_arr_ptr_mut(raw as *mut ArrayHeader); - if arr.is_null() { - return -1; - } - - let Some(start_number) = value_bits_to_number(start.to_bits()) else { - return -1; - }; - if !start_number.is_finite() - || start_number.fract() != 0.0 - || !(0.0..=i32::MAX as f64).contains(&start_number) - { - return -1; - } - let start = start_number as u32; - - let end = match end { - Some(end) => { - let Some(end_number) = value_bits_to_number(end.to_bits()) else { - return -1; - }; - if !end_number.is_finite() - || end_number.fract() != 0.0 - || !(0.0..=i32::MAX as f64).contains(&end_number) - { - return -1; - } - end_number as u32 - } - None => unsafe { (*arr).length }, - }; - let flags = array_object_flags(arr); - if flags & (crate::gc::OBJ_FLAG_FROZEN | crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS) != 0 { - return -1; - } - - unsafe { - if end > (*arr).length || end > (*arr).capacity { - return -1; - } - if start >= end { - return i64::from(start); - } - let elements = (arr as *mut u8).add(std::mem::size_of::()) as *mut u64; - for index in start..end { - if value_bits_to_number(ptr::read(elements.add(index as usize))).is_none() { - return -1; - } - } - for index in start..end { - let slot = elements.add(index as usize); - let number = value_bits_to_number(ptr::read(slot)) - .expect("numeric range was validated before mutation"); - // GC_STORE_AUDIT(POINTER_FREE): both operands were proven numeric, - // so the replacement is an unboxed IEEE-754 value. - ptr::write(slot, (number + delta).to_bits()); - } - } - i64::from(end) -} - -#[no_mangle] -pub extern "C" fn js_array_numeric_range_add( - receiver: f64, - start: f64, - end: f64, - delta: f64, -) -> i64 { - array_numeric_range_add_impl(receiver, start, Some(end), delta) -} - -#[no_mangle] -pub extern "C" fn js_array_numeric_range_add_len(receiver: f64, start: f64, delta: f64) -> i64 { - array_numeric_range_add_impl(receiver, start, None, delta) -} - -#[cfg(feature = "keepalive-anchors")] -#[used] -static KEEP_ARRAY_NUMERIC_RANGE_ADD: extern "C" fn(f64, f64, f64, f64) -> i64 = - js_array_numeric_range_add; -#[cfg(feature = "keepalive-anchors")] -#[used] -static KEEP_ARRAY_NUMERIC_RANGE_ADD_LEN: extern "C" fn(f64, f64, f64) -> i64 = - js_array_numeric_range_add_len; - /// `arr[stringKey] = value` — handles the JS spec rule that numeric-string /// keys on arrays are coerced to integer indices. Pre-fix the codegen's /// IndexSet array fast-path applied `fptosi(double, i32)` directly to the @@ -1869,7 +1823,12 @@ pub extern "C" fn js_array_set_index_or_string_strict( // store, so ToString it and re-parse. let index = canonical_index_of_set_key(idx); if let Some(i) = index { - array_strict_index_write_guard(arr, i); + // The non-strict dispatcher would parse the same key again and + // then enter the extend helper after a separate strict guard. The + // canonical index is already proved here, so use the fused strict + // element path and share one receiver resolution across policy + // and store. + return js_array_set_f64_extend_strict(arr, i, value); } } js_array_set_index_or_string(arr, idx, value) diff --git a/crates/perry-runtime/src/array/iter_methods.rs b/crates/perry-runtime/src/array/iter_methods.rs index 1fc706fd99..4ab5e99859 100644 --- a/crates/perry-runtime/src/array/iter_methods.rs +++ b/crates/perry-runtime/src/array/iter_methods.rs @@ -70,7 +70,30 @@ impl<'s> RootedIterArray<'s> { fn arr(&self) -> *const ArrayHeader { let rooted = (self.handle.get_nanbox_u64() & crate::value::POINTER_MASK) as *const ArrayHeader; - let live = clean_arr_ptr(rooted); + // `RootedIterArray` is private and every constructor call receives the + // non-null, genuine Array result of `normalize_array_receiver` after + // Buffer/TypedArray dispatch. The handle is then either rewritten by + // moving GC to another live Array or still points at an Array-growth + // forwarding stub. Therefore the ordinary (non-forwarded) case can + // read its already-proved header directly instead of re-entering + // `clean_arr_ptr`'s allocator/registry ownership classifier for every + // callback argument and element access. + // + // Growth is the exceptional case that a GC root cannot heal itself: + // `js_array_grow` leaves aliases pointing at the old stub. Keep the + // full resolver there so forwarding-chain validation and compression + // retain their existing corruption defenses. + let live = unsafe { + let header = + (rooted as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; + if (*header).obj_type == crate::gc::GC_TYPE_ARRAY + && (*header).gc_flags & crate::gc::GC_FLAG_FORWARDED == 0 + { + rooted + } else { + clean_arr_ptr(rooted) + } + }; if live != rooted { // Array growth and moving GC leave forwarding stubs behind. Keep // the root current so subsequent loop iterations do not inspect @@ -866,6 +889,64 @@ pub extern "C" fn js_array_some(arr: *const ArrayHeader, callback: *const Closur } } +/// `Array.prototype.some` for a compiler-proved captureless inline arrow. +/// +/// The callback literal is consumed only by `some`, has no observable +/// function identity, and cannot read a closure environment. Passing its code +/// pointer directly avoids the singleton-closure TLS lookup and lets the loop +/// call the body without rebuilding closure dispatch state. Non-Array +/// receivers retain the generic path so Buffer, TypedArray, and array-like +/// semantics remain centralized in [`js_array_some`]. +#[no_mangle] +pub extern "C" fn js_array_some_captureless( + original_arr: *const ArrayHeader, + callback_func: *const u8, +) -> f64 { + const TAG_TRUE: u64 = 0x7FFC_0000_0000_0004; + const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003; + + let arr = normalize_array_receiver(original_arr); + if arr.is_null() { + return f64::from_bits(TAG_FALSE); + } + if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() + || crate::buffer::is_registered_buffer(arr as usize) + { + let callback = crate::closure::js_closure_alloc_singleton(callback_func); + return js_array_some(original_arr, callback); + } + + let callback: extern "C" fn(*const ClosureHeader, f64, f64, f64) -> f64 = + unsafe { std::mem::transmute(callback_func) }; + unsafe { + let length = (*arr).length; + let scope = crate::gc::RuntimeHandleScope::new(); + let rooted = RootedIterArray::new(&scope, arr); + let exotic = crate::array::array_iteration_is_exotic(arr); + + for i in 0..length as usize { + let element = if exotic { + let arr = rooted.arr(); + if !crate::array::array_spec_has_index(arr, i as u32) { + continue; + } + crate::array::array_spec_get(arr, i as u32) + } else { + match rooted.present(i) { + Some(element) => element, + None => continue, + } + }; + let result = callback(std::ptr::null(), element, i as f64, rooted.receiver()); + if crate::value::js_is_truthy(result) != 0 { + return f64::from_bits(TAG_TRUE); + } + } + } + + f64::from_bits(TAG_FALSE) +} + /// every - returns true if all elements match callback(element) => true /// Returns TAG_TRUE or TAG_FALSE as f64 #[no_mangle] diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index 5f35d3492d..8eabcad213 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -19,6 +19,7 @@ mod iter_object; mod iterator; mod join; mod jsvalue_api; +mod numeric_range; mod prototype_addr; mod push_pop; mod reduce_right; @@ -130,18 +131,17 @@ pub(crate) use self::indexing::{ pub use self::indexing::{ js_array_get_element, js_array_get_element_f64, js_array_get_f64, js_array_get_f64_unchecked, js_array_get_index_or_string, js_array_get_length, js_array_length, - js_array_numeric_get_f64_unboxed, js_array_numeric_range_add, js_array_numeric_range_add_len, - js_array_numeric_set_f64_unboxed, js_array_set_f64, js_array_set_f64_extend, - js_array_set_f64_extend_strict, js_array_set_f64_unchecked, js_array_set_index_or_string, - js_array_set_index_or_string_strict, js_array_set_string_key, + js_array_numeric_get_f64_unboxed, js_array_numeric_set_f64_unboxed, js_array_set_f64, + js_array_set_f64_extend, js_array_set_f64_extend_strict, js_array_set_f64_unchecked, + js_array_set_index_or_string, js_array_set_index_or_string_strict, js_array_set_string_key, }; pub use self::is_array::js_array_is_array; pub(crate) use self::iter_methods::throw_reduce_of_empty; pub use self::iter_methods::{ js_array_at, js_array_every, js_array_filter, js_array_find, js_array_findIndex, js_array_find_last, js_array_find_last_index, js_array_flatMap, js_array_forEach, js_array_map, - js_array_map_discard, js_array_reduce, js_array_some, js_array_to_locale_string, - js_validate_array_callback, js_validate_array_map_callback, + js_array_map_discard, js_array_reduce, js_array_some, js_array_some_captureless, + js_array_to_locale_string, js_validate_array_callback, js_validate_array_map_callback, }; pub use self::iter_object::{ arguments_values_iter, array_entries_iter, array_keys_iter, array_values_iter, @@ -154,6 +154,7 @@ pub use self::iterator::{ js_array_spread_append, js_for_of_to_array, js_get_async_iterator, js_iterator_to_array, }; pub use self::join::{js_array_join, js_array_join_value}; +pub use self::numeric_range::{js_array_numeric_range_add, js_array_numeric_range_add_len}; pub use self::prototype_addr::scan_prototype_addr_cache_roots_mut; pub(crate) use self::prototype_addr::{ array_prototype_addr, object_prototype_addr, object_prototype_addr_matches, @@ -220,14 +221,13 @@ pub(crate) use self::header::{ array_named_property_set, array_numeric_raw_f64_get, array_numeric_raw_f64_push_inbounds, array_numeric_raw_f64_set_inbounds, array_object_flags, array_object_flags_from_tag, array_object_flags_resolved, array_ptr_as_proxy, array_receiver_addr, array_receiver_gc_tag, - buffer_receiver_as_uint8_typed_array, canonicalize_array_numeric_store_value, - canonicalize_array_numeric_store_value_from_flags, clean_arr_ptr, clean_arr_ptr_mut, + buffer_receiver_as_uint8_typed_array, clean_arr_ptr, clean_arr_ptr_mut, clear_array_numeric_layout, clear_array_numeric_layout_ptr, gc_element_slot_range, mark_array_layout_unknown, mark_array_raw_f64_holes_fresh, normalize_array_receiver, note_array_slot, note_array_slot_layout_only, rebuild_array_layout, rebuild_array_layout_exact, refresh_array_numeric_layout, replay_array_growth_write_barriers, set_array_numeric_layout, - store_array_slot, transfer_array_numeric_layout, typed_array_receiver, value_bits_to_number, - NumericArrayLayout, MIN_ARRAY_CAPACITY, + store_array_slot, store_array_slot_resolved, transfer_array_numeric_layout, + typed_array_receiver, value_bits_to_number, NumericArrayLayout, MIN_ARRAY_CAPACITY, }; // Sole caller is the regex-engine-gated `regex::exec_array`, so the helper and diff --git a/crates/perry-runtime/src/array/numeric_range.rs b/crates/perry-runtime/src/array/numeric_range.rs new file mode 100644 index 0000000000..b9ac67f8e9 --- /dev/null +++ b/crates/perry-runtime/src/array/numeric_range.rs @@ -0,0 +1,112 @@ +//! Dense numeric-window `arr[i] = arr[i] + delta` kernel +//! (`js_array_numeric_range_add` / `js_array_numeric_range_add_len`), split +//! from `indexing.rs` for the file-size gate (#8872). The transactional +//! validate-then-mutate contract is unchanged. + +use super::header::value_bits_to_number; +use super::*; +use std::ptr; + +/// Try to perform `arr[i] = arr[i] + delta` over a dense numeric window. +/// +/// This is intentionally transactional: the first pass validates the actual +/// runtime receiver and every source slot, and only then does the second pass +/// mutate. Returning `-1` means "run the ordinary JS loop"; no slot has been +/// changed in that case. A non-negative return is the counter value the source +/// loop would have on exit. +fn array_numeric_range_add_impl(receiver: f64, start: f64, end: Option, delta: f64) -> i64 { + let receiver_value = crate::value::JSValue::from_bits(receiver.to_bits()); + if !receiver_value.is_pointer() { + return -1; + } + let raw = receiver_value.as_pointer::() as usize; + let Some(header) = (unsafe { crate::value::addr_class::try_read_gc_header(raw) }) else { + return -1; + }; + if header.obj_type != crate::gc::GC_TYPE_ARRAY { + return -1; + } + let arr = clean_arr_ptr_mut(raw as *mut ArrayHeader); + if arr.is_null() { + return -1; + } + + let Some(start_number) = value_bits_to_number(start.to_bits()) else { + return -1; + }; + if !start_number.is_finite() + || start_number.fract() != 0.0 + || !(0.0..=i32::MAX as f64).contains(&start_number) + { + return -1; + } + let start = start_number as u32; + + let end = match end { + Some(end) => { + let Some(end_number) = value_bits_to_number(end.to_bits()) else { + return -1; + }; + if !end_number.is_finite() + || end_number.fract() != 0.0 + || !(0.0..=i32::MAX as f64).contains(&end_number) + { + return -1; + } + end_number as u32 + } + None => unsafe { (*arr).length }, + }; + let flags = array_object_flags(arr); + if flags & (crate::gc::OBJ_FLAG_FROZEN | crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS) != 0 { + return -1; + } + + unsafe { + if end > (*arr).length || end > (*arr).capacity { + return -1; + } + if start >= end { + return i64::from(start); + } + let elements = (arr as *mut u8).add(std::mem::size_of::()) as *mut u64; + for index in start..end { + if value_bits_to_number(ptr::read(elements.add(index as usize))).is_none() { + return -1; + } + } + for index in start..end { + let slot = elements.add(index as usize); + let number = value_bits_to_number(ptr::read(slot)) + .expect("numeric range was validated before mutation"); + // GC_STORE_AUDIT(POINTER_FREE): both operands were proven numeric, + // so the replacement is an unboxed IEEE-754 value. + ptr::write(slot, (number + delta).to_bits()); + } + } + i64::from(end) +} + +#[no_mangle] +pub extern "C" fn js_array_numeric_range_add( + receiver: f64, + start: f64, + end: f64, + delta: f64, +) -> i64 { + array_numeric_range_add_impl(receiver, start, Some(end), delta) +} + +#[no_mangle] +pub extern "C" fn js_array_numeric_range_add_len(receiver: f64, start: f64, delta: f64) -> i64 { + array_numeric_range_add_impl(receiver, start, None, delta) +} + +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_ARRAY_NUMERIC_RANGE_ADD: extern "C" fn(f64, f64, f64, f64) -> i64 = + js_array_numeric_range_add; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_ARRAY_NUMERIC_RANGE_ADD_LEN: extern "C" fn(f64, f64, f64) -> i64 = + js_array_numeric_range_add_len; diff --git a/crates/perry-runtime/src/array/push_pop.rs b/crates/perry-runtime/src/array/push_pop.rs index 3711d6c23c..ce0ff02c63 100644 --- a/crates/perry-runtime/src/array/push_pop.rs +++ b/crates/perry-runtime/src/array/push_pop.rs @@ -696,12 +696,9 @@ pub extern "C" fn js_array_push_f64(arr: *mut ArrayHeader, value: f64) -> *mut A return js_array_push_f64_grow(arr, length, value); } - let value = canonicalize_array_numeric_store_value_from_flags(flags, value); - let value_bits = value.to_bits(); - let elements_ptr = (arr as *mut u8).add(std::mem::size_of::()) as *mut f64; - // GC_STORE_AUDIT(BARRIERED): push slot is immediately recorded via note_array_slot. - ptr::write(elements_ptr.add(length as usize), value); - note_array_slot(arr, length as usize, value_bits); + // GC_STORE_AUDIT(BARRIERED): the resolved store performs the layout + // note and write barrier as part of the slot write. + store_array_slot_resolved(arr, length as usize, value, flags); (*arr).length = length + 1; arr } @@ -825,13 +822,12 @@ unsafe fn js_array_push_f64_grow( let value_handle = scope.root_nanbox_f64(value); let arr = js_array_grow(arr_handle.get_raw_mut_ptr::(), length + 1); - let value = canonicalize_array_numeric_store_value(arr, value_handle.get_nanbox_f64()); - let value_bits = value.to_bits(); - - let elements_ptr = (arr as *mut u8).add(std::mem::size_of::()) as *mut f64; - // GC_STORE_AUDIT(BARRIERED): grown push slot is immediately recorded via note_array_slot. - ptr::write(elements_ptr.add(length as usize), value); - note_array_slot(arr, length as usize, value_bits); + // SAFETY: `js_array_grow` returns the resolved live array head and no + // safepoint intervenes before the flag read/store. + let flags = array_object_flags_resolved(arr); + // GC_STORE_AUDIT(BARRIERED): the resolved store performs the layout note + // and write barrier as part of the slot write. + store_array_slot_resolved(arr, length as usize, value_handle.get_nanbox_f64(), flags); (*arr).length = length + 1; arr } diff --git a/crates/perry-runtime/src/array/tests.rs b/crates/perry-runtime/src/array/tests.rs index 7ec40a3537..e912b60872 100644 --- a/crates/perry-runtime/src/array/tests.rs +++ b/crates/perry-runtime/src/array/tests.rs @@ -1,6 +1,7 @@ //! Unit tests. use std::ptr; +use std::sync::atomic::{AtomicUsize, Ordering}; use super::*; @@ -13,6 +14,27 @@ extern "C" fn test_map_to_string( f64::from_bits(crate::value::STRING_TAG | (str_ptr as u64 & crate::value::POINTER_MASK)) } +static DIRECT_SOME_CALLS: AtomicUsize = AtomicUsize::new(0); + +extern "C" fn direct_some_is_two( + closure: *const crate::closure::ClosureHeader, + element: f64, + index: f64, + _array: f64, +) -> f64 { + assert!( + closure.is_null(), + "captureless callbacks need no environment" + ); + DIRECT_SOME_CALLS.fetch_add(1, Ordering::Relaxed); + let matches = element == 2.0 && index == 1.0; + f64::from_bits(if matches { + crate::value::TAG_TRUE + } else { + crate::value::TAG_FALSE + }) +} + fn gc_collection_count_for_tests() -> u64 { let mut collections = 0; crate::gc::js_gc_stats(&mut collections, ptr::null_mut(), ptr::null_mut()); @@ -137,6 +159,19 @@ fn test_array_alloc_and_access() { assert_eq!(js_array_get_f64(arr, 5).to_bits(), 0x7FFC_0000_0000_0001u64); } +#[test] +fn captureless_some_calls_the_body_directly_and_short_circuits() { + let mut arr = js_array_alloc(3); + arr = js_array_push_f64(arr, 1.0); + arr = js_array_push_f64(arr, 2.0); + arr = js_array_push_f64(arr, 3.0); + DIRECT_SOME_CALLS.store(0, Ordering::Relaxed); + + let answer = js_array_some_captureless(arr, direct_some_is_two as *const u8); + assert_eq!(answer.to_bits(), crate::value::TAG_TRUE); + assert_eq!(DIRECT_SOME_CALLS.load(Ordering::Relaxed), 2); +} + #[test] fn test_array_hole_is_not_own_property_but_undefined_value_is() { let mut holey = js_array_alloc(0); diff --git a/crates/perry-runtime/src/array/typed_array_receiver_tests.rs b/crates/perry-runtime/src/array/typed_array_receiver_tests.rs index 637639e572..ceb93eeae9 100644 --- a/crates/perry-runtime/src/array/typed_array_receiver_tests.rs +++ b/crates/perry-runtime/src/array/typed_array_receiver_tests.rs @@ -931,6 +931,19 @@ fn js_array_some_sees_a_buffer_receivers_bytes() { assert!(is_true(answer), "node answers true; `false` is #8137"); } +#[test] +fn captureless_some_keeps_the_buffer_receiver_fallback() { + let _serialized = crate::array::test_serialize(); + let buf = subject(); + let answer = crate::array::js_array_some_captureless(buf, cb_is_one as *const u8); + assert_eq!( + observed_values(), + vec![3.0, 1.0], + "the captureless compiler ABI must retain Buffer-backed Uint8Array semantics" + ); + assert!(is_true(answer)); +} + #[test] fn js_array_every_sees_a_buffer_receivers_bytes() { let _serialized = crate::array::test_serialize(); diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index 712a5f5751..1f3d794cd9 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -877,6 +877,33 @@ pub(crate) fn layout_note_slot(parent_user: usize, slot_index: usize, value_bits layout_mark_unknown(parent_user as *mut u8); return; } + // An empty array's first pointer append is also a complete proof of + // the all-pointer invariant: before the caller bumps `length` the + // live prefix is empty, and immediately afterwards its sole element + // is the pointer we just classified. Publish that stronger state + // instead of minting a one-bit side mask (or falling back to UNKNOWN), + // so later pointer appends can consume the same O(1) header proof as + // arrays declared all-pointer by codegen. + // + // This is deliberately restricted to `length == 0`. A POINTER_FREE + // array with an existing numeric prefix may also receive a pointer at + // its append position, but that prefix does not satisfy the claim. + // Clear both raw-f64 flags before publishing ALL_POINTERS: the two + // representations are mutually exclusive, and generated append code + // uses their absence as part of its admission test. + if pointer + && (*header).obj_type == GC_TYPE_ARRAY + && (*header)._reserved & GC_LAYOUT_STATE_MASK == GC_LAYOUT_POINTER_FREE + { + let arr = parent_user as *const crate::array::ArrayHeader; + if (*arr).length == 0 + && layout_all_pointer_array_append(header, parent_user, slot_index) + { + crate::array::clear_array_numeric_layout_ptr(parent_user); + layout_init_all_pointer_slots(parent_user as *mut u8); + return; + } + } if !pointer && (*header)._reserved & GC_LAYOUT_STATE_MASK == GC_LAYOUT_POINTER_FREE { return; } diff --git a/crates/perry-runtime/src/gc/tests/copying/all_pointer_elements_7469.rs b/crates/perry-runtime/src/gc/tests/copying/all_pointer_elements_7469.rs index c97e278c73..2484d87519 100644 --- a/crates/perry-runtime/src/gc/tests/copying/all_pointer_elements_7469.rs +++ b/crates/perry-runtime/src/gc/tests/copying/all_pointer_elements_7469.rs @@ -112,6 +112,54 @@ fn declaring_at_allocation_clears_the_raw_f64_layout_the_allocator_published() { } } +#[test] +fn first_pointer_append_promotes_an_empty_array_to_all_pointer_layout() { + let _guard = CopyingNurseryTestGuard::new(1); + let arr = crate::array::js_array_alloc(4); + let child = fresh_string(b"first_pointer_append"); + let arr = crate::array::js_array_push_f64(arr, f64::from_bits(string_bits(child))); + unsafe { + assert_eq!((*arr).length, 1, "the witness append must be live"); + assert!( + codegen_would_take_the_elided_store(arr), + "an empty array's first pointer append proves the complete live \ + prefix is all-pointer and must clear the raw-f64 claim" + ); + assert_eq!( + test_heap_child_slot_count(arr as *mut u8), + 1, + "the promoted layout must expose the appended child to the collector" + ); + } +} + +#[test] +fn later_non_pointer_append_revokes_automatic_all_pointer_layout() { + let _guard = CopyingNurseryTestGuard::new(1); + let arr = crate::array::js_array_alloc(4); + let child = fresh_string(b"pointer_then_number"); + let arr = crate::array::js_array_push_f64(arr, f64::from_bits(string_bits(child))); + unsafe { + assert!(codegen_would_take_the_elided_store(arr)); + } + + let arr = crate::array::js_array_push_f64(arr, 1.0); + unsafe { + assert_eq!((*arr).length, 2); + assert!( + !codegen_would_take_the_elided_store(arr), + "a non-pointer append must retire the automatically established \ + all-pointer proof before generated code can consume it" + ); + assert_eq!( + test_heap_child_slot_count(arr as *mut u8), + 2, + "revocation may conservatively scan the mixed live prefix, but \ + it must not lose either initialized slot" + ); + } +} + /// The runtime CAN revoke the declaration behind codegen's back, and this is /// the cheapest live demonstration of it: `js_array_is_numeric_f64_layout` on a /// still-EMPTY declared array verifies vacuously and re-publishes the array as diff --git a/crates/perry-runtime/src/gc/tests/layout_trace/element_shape.rs b/crates/perry-runtime/src/gc/tests/layout_trace/element_shape.rs index ecae44cd21..b64755b4ea 100644 --- a/crates/perry-runtime/src/gc/tests/layout_trace/element_shape.rs +++ b/crates/perry-runtime/src/gc/tests/layout_trace/element_shape.rs @@ -19,17 +19,22 @@ fn shaped_instance() -> f64 { crate::value::js_nanbox_pointer(obj as i64) } -/// `const rows = []; rows.push(new C()); …` built through the real push -/// funnel, so the invariant is established the way production establishes it. +/// `const rows = []; rows.push(new C()); …` followed by the proof request an +/// optimized production loop emits before entering its fast clone. fn proven_array(count: usize) -> *mut crate::array::ArrayHeader { let mut arr = crate::array::js_array_alloc(count as u32); for _ in 0..count { arr = crate::array::js_array_push_f64(arr, shaped_instance()); } + assert_ne!( + crate::array::js_array_ensure_element_shape(arr), + 0, + "the generated consumer must establish the invariant before the GC test starts" + ); assert_eq!( crate::array::js_array_element_shape_class(arr), CLASS_ELEM as i32, - "the push funnel must establish the invariant before the GC test starts" + "the requested proof must be readable before the GC test starts" ); arr } diff --git a/crates/perry-transform/src/aggregate_scalar.rs b/crates/perry-transform/src/aggregate_scalar.rs index d6e82bd01e..65ef792c05 100644 --- a/crates/perry-transform/src/aggregate_scalar.rs +++ b/crates/perry-transform/src/aggregate_scalar.rs @@ -205,6 +205,45 @@ fn scalarize_stmts( region_refs: &HashSet, reference_region_counts: &HashMap, ) { + // Early-return inlining represents a returned value as + // + // let result = undefined; + // do { ...; result = new __AnonShape(...); break; } while (false); + // + // The ordinary codegen scalar-replacement collector only sees a `New` + // directly in a Let initializer, so this canonical merge used to force a + // heap record even when every consumer merely read known fields. Promote + // those merge records to one mutable scalar local per field first. + let return_record_candidates: Vec = stmts + .windows(2) + .filter_map(|pair| match (&pair[0], &pair[1]) { + ( + Stmt::Let { + id, + mutable: true, + init: Some(Expr::Undefined), + .. + }, + Stmt::DoWhile { + condition: Expr::Bool(false), + .. + }, + ) => Some(*id), + _ => None, + }) + .collect(); + for record_id in return_record_candidates { + let _ = scalarize_return_record_candidate( + stmts, + record_id, + next_local_id, + source_span_remaps, + anon_shape_fields, + region_refs, + reference_region_counts, + ); + } + let candidates: Vec = stmts .iter() .filter_map(|stmt| match stmt { @@ -357,6 +396,770 @@ fn scalarize_stmts( } } +fn scalarize_return_record_candidate( + stmts: &mut Vec, + record_id: LocalId, + next_local_id: &mut LocalId, + source_span_remaps: &mut Vec<(LocalId, LocalId)>, + anon_shape_fields: &AnonShapeFields, + region_refs: &HashSet, + reference_region_counts: &HashMap, +) -> bool { + let own_region_reference = usize::from(region_refs.contains(&record_id)); + if reference_region_counts + .get(&record_id) + .copied() + .unwrap_or_default() + > own_region_reference + { + return false; + } + let Some(declaration_index) = stmts.iter().position(|stmt| { + matches!( + stmt, + Stmt::Let { + id, + mutable: true, + init: Some(Expr::Undefined), + .. + } if *id == record_id + ) + }) else { + return false; + }; + let Some(Stmt::DoWhile { + body: merge_body, + condition: Expr::Bool(false), + }) = stmts.get(declaration_index + 1) + else { + return false; + }; + + let mut assigned_shapes = Vec::new(); + collect_return_record_assignments(merge_body, record_id, &mut assigned_shapes); + if assigned_shapes.is_empty() { + return false; + } + let mut field_order = Vec::new(); + let mut admitted_shapes: HashMap> = HashMap::new(); + for class_name in assigned_shapes { + let Some(fields) = anon_shape_fields.get(&class_name) else { + return false; + }; + if fields.is_empty() { + return false; + } + for field in fields { + if !field_order.contains(field) { + field_order.push(field.clone()); + } + } + admitted_shapes.insert(class_name, fields.clone()); + } + if field_order.is_empty() || field_order.len() > MAX_SCALAR_AGGREGATE_FIELDS { + return false; + } + + // Every exit from the synthetic do/while that reaches subsequent field + // reads must first write a record. `return undefined` becomes a bare + // Break; rejecting such a break preserves the original TypeError behavior + // instead of silently turning it into an all-undefined record. + if !merge_body_ends_with_record_assignment(merge_body, record_id, &admitted_shapes) { + return false; + } + + if !return_record_stmts_are_safe( + merge_body, + record_id, + &admitted_shapes, + &field_order, + true, + false, + ) { + return false; + } + for (index, stmt) in stmts.iter().enumerate() { + if index == declaration_index || index == declaration_index + 1 { + continue; + } + if !return_record_stmts_are_safe( + std::slice::from_ref(stmt), + record_id, + &admitted_shapes, + &field_order, + false, + true, + ) { + return false; + } + } + + let mut field_locals = HashMap::new(); + let mut replacement_declarations = Vec::with_capacity(field_order.len()); + for (index, field) in field_order.iter().enumerate() { + let id = *next_local_id; + *next_local_id = next_local_id.saturating_add(1); + source_span_remaps.push((record_id, id)); + field_locals.insert(field.clone(), id); + replacement_declarations.push(Stmt::Let { + id, + name: format!("__perry_return_record_{record_id}_{index}"), + ty: Type::Any, + mutable: true, + init: Some(Expr::Undefined), + }); + } + + rewrite_return_record_stmts( + stmts, + record_id, + &admitted_shapes, + &field_order, + &field_locals, + ); + let Some(declaration_index) = stmts + .iter() + .position(|stmt| matches!(stmt, Stmt::Let { id, .. } if *id == record_id)) + else { + return false; + }; + stmts.splice( + declaration_index..=declaration_index, + replacement_declarations, + ); + true +} + +fn collect_return_record_assignments( + stmts: &[Stmt], + record_id: LocalId, + assigned_shapes: &mut Vec, +) { + for stmt in stmts { + if let Stmt::Expr(Expr::LocalSet(id, value)) = stmt { + if *id == record_id { + if let Expr::New { class_name, .. } = value.as_ref() { + assigned_shapes.push(class_name.clone()); + } + } + } + match stmt { + Stmt::If { + then_branch, + else_branch, + .. + } => { + collect_return_record_assignments(then_branch, record_id, assigned_shapes); + if let Some(else_branch) = else_branch { + collect_return_record_assignments(else_branch, record_id, assigned_shapes); + } + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } | Stmt::For { body, .. } => { + collect_return_record_assignments(body, record_id, assigned_shapes) + } + Stmt::Try { + body, + catch, + finally, + } => { + collect_return_record_assignments(body, record_id, assigned_shapes); + if let Some(catch) = catch { + collect_return_record_assignments(&catch.body, record_id, assigned_shapes); + } + if let Some(finally) = finally { + collect_return_record_assignments(finally, record_id, assigned_shapes); + } + } + Stmt::Switch { cases, .. } => { + for case in cases { + collect_return_record_assignments(&case.body, record_id, assigned_shapes); + } + } + Stmt::Labeled { body, .. } => collect_return_record_assignments( + std::slice::from_ref(body.as_ref()), + record_id, + assigned_shapes, + ), + _ => {} + } + } +} + +fn is_return_record_assignment( + stmt: &Stmt, + record_id: LocalId, + admitted_shapes: &HashMap>, +) -> bool { + matches!( + stmt, + Stmt::Expr(Expr::LocalSet(id, value)) + if *id == record_id + && matches!(value.as_ref(), Expr::New { class_name, .. } if admitted_shapes.contains_key(class_name)) + ) +} + +fn merge_body_ends_with_record_assignment( + stmts: &[Stmt], + record_id: LocalId, + admitted_shapes: &HashMap>, +) -> bool { + // The wrapper's fallthrough must also be a converted return. + if !matches!(stmts.last(), Some(Stmt::Break)) + || stmts.len() < 2 + || !is_return_record_assignment(&stmts[stmts.len() - 2], record_id, admitted_shapes) + { + return false; + } + + for (index, stmt) in stmts.iter().enumerate() { + match stmt { + Stmt::Break => { + if index == 0 + || !is_return_record_assignment(&stmts[index - 1], record_id, admitted_shapes) + { + return false; + } + } + Stmt::If { + then_branch, + else_branch, + .. + } => { + if !merge_nested_breaks_follow_assignment(then_branch, record_id, admitted_shapes) + || else_branch.as_ref().is_some_and(|branch| { + !merge_nested_breaks_follow_assignment(branch, record_id, admitted_shapes) + }) + { + return false; + } + } + Stmt::Try { + body, + catch, + finally, + } => { + if !merge_nested_breaks_follow_assignment(body, record_id, admitted_shapes) + || catch.as_ref().is_some_and(|catch| { + !merge_nested_breaks_follow_assignment( + &catch.body, + record_id, + admitted_shapes, + ) + }) + || finally.as_ref().is_some_and(|finally| { + !merge_nested_breaks_follow_assignment(finally, record_id, admitted_shapes) + }) + { + return false; + } + } + Stmt::Switch { cases, .. } => { + if cases.iter().any(|case| { + !merge_nested_breaks_follow_assignment(&case.body, record_id, admitted_shapes) + }) { + return false; + } + } + // Breaks in a nested loop target that loop rather than this + // synthetic wrapper and are deliberately not inspected here. + _ => {} + } + } + true +} + +fn merge_nested_breaks_follow_assignment( + stmts: &[Stmt], + record_id: LocalId, + admitted_shapes: &HashMap>, +) -> bool { + for (index, stmt) in stmts.iter().enumerate() { + match stmt { + Stmt::Break => { + if index == 0 + || !is_return_record_assignment(&stmts[index - 1], record_id, admitted_shapes) + { + return false; + } + } + Stmt::If { + then_branch, + else_branch, + .. + } => { + if !merge_nested_breaks_follow_assignment(then_branch, record_id, admitted_shapes) + || else_branch.as_ref().is_some_and(|branch| { + !merge_nested_breaks_follow_assignment(branch, record_id, admitted_shapes) + }) + { + return false; + } + } + Stmt::Try { + body, + catch, + finally, + } => { + if !merge_nested_breaks_follow_assignment(body, record_id, admitted_shapes) + || catch.as_ref().is_some_and(|catch| { + !merge_nested_breaks_follow_assignment( + &catch.body, + record_id, + admitted_shapes, + ) + }) + || finally.as_ref().is_some_and(|finally| { + !merge_nested_breaks_follow_assignment(finally, record_id, admitted_shapes) + }) + { + return false; + } + } + Stmt::Switch { cases, .. } => { + if cases.iter().any(|case| { + !merge_nested_breaks_follow_assignment(&case.body, record_id, admitted_shapes) + }) { + return false; + } + } + Stmt::While { .. } | Stmt::DoWhile { .. } | Stmt::For { .. } => {} + _ => {} + } + } + true +} + +fn return_record_stmts_are_safe( + stmts: &[Stmt], + record_id: LocalId, + admitted_shapes: &HashMap>, + field_order: &[String], + allow_assignments: bool, + allow_reads: bool, +) -> bool { + fn expr_is_safe( + expr: &Expr, + record_id: LocalId, + field_order: &[String], + allow_reads: bool, + ) -> bool { + if let Expr::PropertyGet { + object, property, .. + } = expr + { + if matches!(object.as_ref(), Expr::LocalGet(id) if *id == record_id) { + return allow_reads && field_order.contains(property); + } + } + if matches!(expr, Expr::LocalGet(id) if *id == record_id) + || matches!(expr, Expr::LocalSet(id, _) if *id == record_id) + || matches!(expr, Expr::Update { id, .. } if *id == record_id) + { + return false; + } + if let Expr::Closure { body, .. } = expr { + return return_record_stmts_are_safe( + body, + record_id, + &HashMap::new(), + field_order, + false, + false, + ); + } + let mut safe = true; + perry_hir::walker::walk_expr_children(expr, &mut |child| { + if !expr_is_safe(child, record_id, field_order, allow_reads) { + safe = false; + } + }); + safe + } + + for stmt in stmts { + if allow_assignments && is_return_record_assignment(stmt, record_id, admitted_shapes) { + let Stmt::Expr(Expr::LocalSet(_, value)) = stmt else { + unreachable!(); + }; + let Expr::New { args, .. } = value.as_ref() else { + unreachable!(); + }; + if args + .iter() + .any(|arg| !expr_is_safe(arg, record_id, field_order, false)) + { + return false; + } + continue; + } + let expressions_safe = match stmt { + Stmt::Let { init, .. } => init + .as_ref() + .is_none_or(|expr| expr_is_safe(expr, record_id, field_order, allow_reads)), + Stmt::Expr(expr) | Stmt::Throw(expr) | Stmt::Return(Some(expr)) => { + expr_is_safe(expr, record_id, field_order, allow_reads) + } + Stmt::If { condition, .. } + | Stmt::While { condition, .. } + | Stmt::DoWhile { condition, .. } => { + expr_is_safe(condition, record_id, field_order, allow_reads) + } + Stmt::For { + init, + condition, + update, + .. + } => { + init.as_deref().is_none_or(|init| { + return_record_stmts_are_safe( + std::slice::from_ref(init), + record_id, + admitted_shapes, + field_order, + allow_assignments, + allow_reads, + ) + }) && condition + .as_ref() + .is_none_or(|expr| expr_is_safe(expr, record_id, field_order, allow_reads)) + && update + .as_ref() + .is_none_or(|expr| expr_is_safe(expr, record_id, field_order, allow_reads)) + } + Stmt::Switch { discriminant, .. } => { + expr_is_safe(discriminant, record_id, field_order, allow_reads) + } + Stmt::PreallocateBoxes(ids) + | Stmt::PreallocateTdzBoxes(ids) + | Stmt::ReleaseBoxes(ids) => !ids.contains(&record_id), + _ => true, + }; + if !expressions_safe { + return false; + } + match stmt { + Stmt::If { + then_branch, + else_branch, + .. + } => { + if !return_record_stmts_are_safe( + then_branch, + record_id, + admitted_shapes, + field_order, + allow_assignments, + allow_reads, + ) || else_branch.as_ref().is_some_and(|branch| { + !return_record_stmts_are_safe( + branch, + record_id, + admitted_shapes, + field_order, + allow_assignments, + allow_reads, + ) + }) { + return false; + } + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } | Stmt::For { body, .. } => { + if !return_record_stmts_are_safe( + body, + record_id, + admitted_shapes, + field_order, + allow_assignments, + allow_reads, + ) { + return false; + } + } + Stmt::Try { + body, + catch, + finally, + } => { + if !return_record_stmts_are_safe( + body, + record_id, + admitted_shapes, + field_order, + allow_assignments, + allow_reads, + ) || catch.as_ref().is_some_and(|catch| { + !return_record_stmts_are_safe( + &catch.body, + record_id, + admitted_shapes, + field_order, + allow_assignments, + allow_reads, + ) + }) || finally.as_ref().is_some_and(|finally| { + !return_record_stmts_are_safe( + finally, + record_id, + admitted_shapes, + field_order, + allow_assignments, + allow_reads, + ) + }) { + return false; + } + } + Stmt::Switch { cases, .. } => { + for case in cases { + if case.test.as_ref().is_some_and(|test| { + !expr_is_safe(test, record_id, field_order, allow_reads) + }) || !return_record_stmts_are_safe( + &case.body, + record_id, + admitted_shapes, + field_order, + allow_assignments, + allow_reads, + ) { + return false; + } + } + } + Stmt::Labeled { body, .. } => { + if !return_record_stmts_are_safe( + std::slice::from_ref(body.as_ref()), + record_id, + admitted_shapes, + field_order, + allow_assignments, + allow_reads, + ) { + return false; + } + } + _ => {} + } + } + true +} + +fn rewrite_return_record_stmts( + stmts: &mut Vec, + record_id: LocalId, + admitted_shapes: &HashMap>, + field_order: &[String], + field_locals: &HashMap, +) { + let mut index = 0; + while index < stmts.len() { + if is_return_record_assignment(&stmts[index], record_id, admitted_shapes) { + let Stmt::Expr(Expr::LocalSet(_, value)) = &stmts[index] else { + unreachable!(); + }; + let Expr::New { + class_name, args, .. + } = value.as_ref() + else { + unreachable!(); + }; + let shape_fields = admitted_shapes + .get(class_name) + .expect("assignment shape was admitted"); + let mut replacement = Vec::new(); + for (argument_index, argument) in args.iter().enumerate() { + if let Some(field) = shape_fields.get(argument_index) { + replacement.push(Stmt::Expr(Expr::LocalSet( + *field_locals.get(field).expect("field local exists"), + Box::new(argument.clone()), + ))); + } else { + replacement.push(Stmt::Expr(argument.clone())); + } + } + for field in field_order { + if !shape_fields.contains(field) { + replacement.push(Stmt::Expr(Expr::LocalSet( + *field_locals.get(field).expect("field local exists"), + Box::new(Expr::Undefined), + ))); + } + } + let replacement_len = replacement.len(); + stmts.splice(index..=index, replacement); + index += replacement_len; + continue; + } + + match &mut stmts[index] { + Stmt::Let { init, .. } => { + if let Some(expr) = init { + rewrite_return_record_expr(expr, record_id, field_locals); + } + } + Stmt::Expr(expr) | Stmt::Throw(expr) | Stmt::Return(Some(expr)) => { + rewrite_return_record_expr(expr, record_id, field_locals) + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + rewrite_return_record_expr(condition, record_id, field_locals); + rewrite_return_record_stmts( + then_branch, + record_id, + admitted_shapes, + field_order, + field_locals, + ); + if let Some(else_branch) = else_branch { + rewrite_return_record_stmts( + else_branch, + record_id, + admitted_shapes, + field_order, + field_locals, + ); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + rewrite_return_record_expr(condition, record_id, field_locals); + rewrite_return_record_stmts( + body, + record_id, + admitted_shapes, + field_order, + field_locals, + ); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(init) = init { + let mut init_stmts = vec![*init.clone()]; + rewrite_return_record_stmts( + &mut init_stmts, + record_id, + admitted_shapes, + field_order, + field_locals, + ); + if init_stmts.len() == 1 { + **init = init_stmts.remove(0); + } + } + if let Some(condition) = condition { + rewrite_return_record_expr(condition, record_id, field_locals); + } + if let Some(update) = update { + rewrite_return_record_expr(update, record_id, field_locals); + } + rewrite_return_record_stmts( + body, + record_id, + admitted_shapes, + field_order, + field_locals, + ); + } + Stmt::Try { + body, + catch, + finally, + } => { + rewrite_return_record_stmts( + body, + record_id, + admitted_shapes, + field_order, + field_locals, + ); + if let Some(catch) = catch { + rewrite_return_record_stmts( + &mut catch.body, + record_id, + admitted_shapes, + field_order, + field_locals, + ); + } + if let Some(finally) = finally { + rewrite_return_record_stmts( + finally, + record_id, + admitted_shapes, + field_order, + field_locals, + ); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + rewrite_return_record_expr(discriminant, record_id, field_locals); + for case in cases { + if let Some(test) = &mut case.test { + rewrite_return_record_expr(test, record_id, field_locals); + } + rewrite_return_record_stmts( + &mut case.body, + record_id, + admitted_shapes, + field_order, + field_locals, + ); + } + } + Stmt::Labeled { body, .. } => { + let mut body_stmts = vec![*body.clone()]; + rewrite_return_record_stmts( + &mut body_stmts, + record_id, + admitted_shapes, + field_order, + field_locals, + ); + if body_stmts.len() == 1 { + **body = body_stmts.remove(0); + } + } + _ => {} + } + index += 1; + } +} + +fn rewrite_return_record_expr( + expr: &mut Expr, + record_id: LocalId, + field_locals: &HashMap, +) { + if let Expr::PropertyGet { + object, property, .. + } = expr + { + if matches!(object.as_ref(), Expr::LocalGet(id) if *id == record_id) { + if let Some(field_id) = field_locals.get(property) { + *expr = Expr::LocalGet(*field_id); + return; + } + } + } + if let Expr::Closure { body, .. } = expr { + // Safety analysis permits closure reads only when they remain within + // the same HIR region; rewrite their bodies explicitly because the + // generic expression walker does not descend into closures. + rewrite_return_record_stmts(body, record_id, &HashMap::new(), &[], field_locals); + } + perry_hir::walker::walk_expr_children_mut(expr, &mut |child| { + rewrite_return_record_expr(child, record_id, field_locals) + }); +} + fn element_properties( element: &Expr, anon_shape_fields: &AnonShapeFields, @@ -1044,4 +1847,119 @@ mod tests { ) })); } + + fn shape_new(name: &str, args: Vec) -> Expr { + Expr::New { + class_name: name.to_string(), + args, + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + } + } + + fn returned_record_fixture(with_undefined_exit: bool, observe_identity: bool) -> Vec { + let early_exit = if with_undefined_exit { + vec![Stmt::Break] + } else { + vec![ + Stmt::Expr(Expr::LocalSet( + 1, + Box::new(shape_new("__AnonShape_short", vec![Expr::Integer(1)])), + )), + Stmt::Break, + ] + }; + vec![ + Stmt::Let { + id: 1, + name: "result".to_string(), + ty: Type::Any, + mutable: true, + init: Some(Expr::Undefined), + }, + Stmt::DoWhile { + body: vec![ + Stmt::If { + condition: Expr::Bool(false), + then_branch: early_exit, + else_branch: None, + }, + Stmt::Expr(Expr::LocalSet( + 1, + Box::new(shape_new( + "__AnonShape_long", + vec![Expr::Integer(2), Expr::Integer(7)], + )), + )), + Stmt::Break, + ], + condition: Expr::Bool(false), + }, + Stmt::Expr(if observe_identity { + Expr::Compare { + op: CompareOp::Eq, + left: Box::new(Expr::LocalGet(1)), + right: Box::new(Expr::LocalGet(1)), + } + } else { + property(Expr::LocalGet(1), "detail") + }), + ] + } + + fn scalarize_return_fixture(stmts: &mut Vec) -> bool { + let shapes = HashMap::from([ + ("__AnonShape_short".to_string(), vec!["type".to_string()]), + ( + "__AnonShape_long".to_string(), + vec!["type".to_string(), "detail".to_string()], + ), + ]); + let mut next_local_id = 100; + let mut source_span_remaps = Vec::new(); + scalarize_return_record_candidate( + stmts, + 1, + &mut next_local_id, + &mut source_span_remaps, + &shapes, + &HashSet::from([1]), + &HashMap::from([(1, 1)]), + ) + } + + #[test] + fn scalarizes_multi_shape_inlined_return_record() { + let mut stmts = returned_record_fixture(false, false); + assert!(scalarize_return_fixture(&mut stmts)); + + assert!(!stmts + .iter() + .any(|stmt| matches!(stmt, Stmt::Let { id: 1, .. }))); + assert!(stmts.iter().any(|stmt| { + matches!(stmt, Stmt::Let { name, .. } if name == "__perry_return_record_1_0") + })); + assert!(matches!( + stmts.last(), + Some(Stmt::Expr(Expr::LocalGet(101))) + )); + let debug = format!("{stmts:?}"); + assert!(!debug.contains("__AnonShape_")); + assert!(debug.contains("LocalSet(101, Undefined)")); + } + + #[test] + fn undefined_exit_keeps_inlined_return_record_materialized() { + let mut stmts = returned_record_fixture(true, false); + assert!(!scalarize_return_fixture(&mut stmts)); + assert!(format!("{stmts:?}").contains("__AnonShape_long")); + } + + #[test] + fn identity_observation_keeps_inlined_return_record_materialized() { + let mut stmts = returned_record_fixture(false, true); + assert!(!scalarize_return_fixture(&mut stmts)); + assert!(format!("{stmts:?}").contains("__AnonShape_short")); + } } diff --git a/crates/perry-transform/src/inline/call_inliner.rs b/crates/perry-transform/src/inline/call_inliner.rs index 5f153d7b30..e641c339ba 100644 --- a/crates/perry-transform/src/inline/call_inliner.rs +++ b/crates/perry-transform/src/inline/call_inliner.rs @@ -111,6 +111,87 @@ pub fn convert_returns_in_stmts(stmts: &mut Vec, let_id: LocalId) { } } +/// A destructuring source is wrapped in the HIR-only +/// `requireObjectCoercible(value, sourceOffset)` intrinsic. When `value` is a +/// control-flow function call, that wrapper otherwise hides the call from the +/// statement-level inliner. It is safe to inline through the wrapper when the +/// callee has an unconditional trailing anonymous-record return and every +/// other explicit return is also an anonymous record: successful evaluation +/// can never produce null/undefined, while throws still propagate normally. +fn returns_only_anonymous_records(function: &Function) -> bool { + fn inspect(stmts: &[Stmt], saw_return: &mut bool) -> bool { + for stmt in stmts { + match stmt { + Stmt::Return(Some(Expr::New { + class_name, + cap_args_appended: 0, + .. + })) if class_name.starts_with("__AnonShape_") => *saw_return = true, + Stmt::Return(_) => return false, + Stmt::If { + then_branch, + else_branch, + .. + } => { + if !inspect(then_branch, saw_return) + || else_branch + .as_ref() + .is_some_and(|branch| !inspect(branch, saw_return)) + { + return false; + } + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } | Stmt::For { body, .. } => { + if !inspect(body, saw_return) { + return false; + } + } + Stmt::Try { + body, + catch, + finally, + } => { + if !inspect(body, saw_return) + || catch + .as_ref() + .is_some_and(|catch| !inspect(&catch.body, saw_return)) + || finally + .as_ref() + .is_some_and(|finally| !inspect(finally, saw_return)) + { + return false; + } + } + Stmt::Switch { cases, .. } => { + if cases.iter().any(|case| !inspect(&case.body, saw_return)) { + return false; + } + } + Stmt::Labeled { body, .. } => { + if !inspect(std::slice::from_ref(body.as_ref()), saw_return) { + return false; + } + } + _ => {} + } + } + true + } + + if !matches!( + function.body.last(), + Some(Stmt::Return(Some(Expr::New { + class_name, + cap_args_appended: 0, + .. + }))) if class_name.starts_with("__AnonShape_") + ) { + return false; + } + let mut saw_return = false; + inspect(&function.body, &mut saw_return) && saw_return +} + /// Inline function and method calls in a list of statements. /// /// `enclosing_class`, when set, names the class whose method body these stmts @@ -324,9 +405,35 @@ pub fn inline_calls_in_stmts( _ => unreachable!(), }; let mut handled = false; - if matches!(&init_expr, Expr::Call { .. }) { + let direct_call = match &init_expr { + Expr::Call { .. } => Some(init_expr.clone()), + Expr::NativeMethodCall { + module, + class_name: None, + object: None, + method, + args, + } if module == "__perry_runtime" + && method == "requireObjectCoercible" + && matches!( + args.as_slice(), + [Expr::Call { .. }, Expr::Number(_) | Expr::Integer(_)] + ) => + { + let call = &args[0]; + let eligible = matches!( + call, + Expr::Call { callee, .. } + if matches!(callee.as_ref(), Expr::FuncRef(id) + if func_candidates.get(id).is_some_and(returns_only_anonymous_records)) + ); + eligible.then(|| call.clone()) + } + _ => None, + }; + if let Some(direct_call) = direct_call { if let Some((mut inlined_stmts, _)) = try_inline_call( - &init_expr, + &direct_call, func_candidates, method_candidates, local_types, diff --git a/crates/perry-transform/src/inline/cross_module.rs b/crates/perry-transform/src/inline/cross_module.rs index b66342ad95..de67a2a716 100644 --- a/crates/perry-transform/src/inline/cross_module.rs +++ b/crates/perry-transform/src/inline/cross_module.rs @@ -1,6 +1,7 @@ -use perry_hir::walker::walk_expr_children; -use perry_hir::{Class, Expr, Module, Stmt}; -use std::collections::{HashMap, HashSet}; +use perry_hir::types::{FuncId, LocalId}; +use perry_hir::walker::{walk_expr_children, walk_expr_children_mut}; +use perry_hir::{Class, Expr, Function, ImportSpecifier, Module, Stmt}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; use super::*; @@ -113,6 +114,872 @@ pub fn gather_cross_module_anon_classes(module: &Module) -> HashMap HashMap { + let functions: HashMap = + module.functions.iter().map(|f| (f.id, f)).collect(); + let mut import_by_local: HashMap = HashMap::new(); + for import in &module.imports { + let Some(path) = &import.resolved_path else { + continue; + }; + for specifier in &import.specifiers { + if let ImportSpecifier::Named { imported, local } = specifier { + import_by_local.insert( + local.clone(), + RequiredExternImport { + local: local.clone(), + imported: imported.clone(), + resolved_path: path.clone(), + }, + ); + } + } + } + + // A destination that imports only a function does not necessarily import + // the source module's classes. Anon-shape classes are the one safe + // exception: their names are content-addressed and the existing anon-class + // propagation pass installs their definitions in the destination. + let source_class_names: HashSet = module + .classes + .iter() + .flat_map(|class| std::iter::once(class.name.clone()).chain(class.aliases.iter().cloned())) + .filter(|name| !name.starts_with("__AnonShape_")) + .collect(); + + let mut out = HashMap::new(); + for (exported_name, root_id) in &module.exported_functions { + let mut visiting = HashSet::new(); + let mut visited = HashSet::new(); + let mut graph_ids = Vec::new(); + if !collect_function_graph( + *root_id, + &functions, + &mut visiting, + &mut visited, + &mut graph_ids, + ) { + continue; + } + if graph_ids.len() > MAX_CROSS_MODULE_FUNCTION_GRAPH { + continue; + } + + let allowed_ids: HashSet = graph_ids.iter().copied().collect(); + let mut extern_names = Vec::new(); + let mut graph = Vec::with_capacity(graph_ids.len()); + let mut stmt_count = 0usize; + let mut safe = true; + for id in graph_ids { + let Some(function) = functions.get(&id).copied() else { + safe = false; + break; + }; + stmt_count = stmt_count.saturating_add(recursive_stmt_count(&function.body)); + if stmt_count > MAX_CROSS_MODULE_FUNCTION_STMTS + || !function_shell_is_cross_module_safe(function, &allowed_ids, &mut extern_names) + || body_references_class_in_set(&function.body, &source_class_names) + { + safe = false; + break; + } + graph.push(function.clone()); + } + if !safe { + continue; + } + + extern_names.sort(); + extern_names.dedup(); + let mut required = Vec::with_capacity(extern_names.len()); + for name in extern_names { + let Some(import) = import_by_local.get(&name) else { + safe = false; + break; + }; + required.push(import.clone()); + } + if !safe { + continue; + } + required.sort_by(|a, b| { + (&a.resolved_path, &a.imported, &a.local).cmp(&( + &b.resolved_path, + &b.imported, + &b.local, + )) + }); + required.dedup(); + out.insert( + exported_name.clone(), + FunctionCandidate { + root_id: *root_id, + functions: graph, + required_extern_imports: required, + }, + ); + } + out +} + +fn collect_function_graph( + id: FuncId, + functions: &HashMap, + visiting: &mut HashSet, + visited: &mut HashSet, + out: &mut Vec, +) -> bool { + if visited.contains(&id) { + return true; + } + if !visiting.insert(id) { + return false; + } + let Some(function) = functions.get(&id).copied() else { + return false; + }; + if !is_inlinable(function) + || !function.decorators.is_empty() + || function + .params + .iter() + .any(|param| !param.decorators.is_empty() || param.arguments_object.is_some()) + { + return false; + } + let mut refs = Vec::new(); + collect_func_refs_in_function(function, &mut refs); + refs.sort_unstable(); + refs.dedup(); + for dependency in refs { + if !collect_function_graph(dependency, functions, visiting, visited, out) { + return false; + } + if visited.len() > MAX_CROSS_MODULE_FUNCTION_GRAPH { + return false; + } + } + visiting.remove(&id); + visited.insert(id); + out.push(id); + true +} + +fn collect_func_refs_in_function(function: &Function, out: &mut Vec) { + for param in &function.params { + if let Some(default) = ¶m.default { + collect_func_refs_in_expr(default, out); + } + } + collect_func_refs_in_stmts(&function.body, out); +} + +fn collect_func_refs_in_expr(expr: &Expr, out: &mut Vec) { + if let Expr::FuncRef(id) = expr { + out.push(*id); + } + if let Expr::Closure { params, body, .. } = expr { + for param in params { + if let Some(default) = ¶m.default { + collect_func_refs_in_expr(default, out); + } + } + collect_func_refs_in_stmts(body, out); + } + walk_expr_children(expr, &mut |child| collect_func_refs_in_expr(child, out)); +} + +fn collect_func_refs_in_stmts(stmts: &[Stmt], out: &mut Vec) { + walk_stmts(stmts, &mut |expr| collect_func_refs_in_expr(expr, out)); +} + +fn function_shell_is_cross_module_safe( + function: &Function, + allowed_ids: &HashSet, + extern_names: &mut Vec, +) -> bool { + if !function.captures.is_empty() || !function_locals_are_self_contained(function) { + return false; + } + function.params.iter().all(|param| { + param + .default + .as_ref() + .is_none_or(|default| cross_function_expr_is_safe(default, allowed_ids, extern_names)) + }) && cross_function_stmts_are_safe(&function.body, allowed_ids, extern_names) +} + +/// LocalIds are meaningful only inside their defining module. A top-level +/// function can read a module binding (for example `COMPONENT_ID_MAX`) as a +/// plain LocalGet without listing it as a closure capture. Copying that body +/// into another module would silently bind the numeric id to an unrelated +/// destination local. Admit only references declared by the function itself. +fn function_locals_are_self_contained(function: &Function) -> bool { + let mut declared: HashSet = function.params.iter().map(|param| param.id).collect(); + collect_declared_local_ids(&function.body, &mut declared); + + let mut refs = Vec::new(); + let mut visited = HashSet::new(); + for param in &function.params { + if let Some(default) = ¶m.default { + perry_hir::collect_local_refs_expr(default, &mut refs, &mut visited); + } + } + for stmt in &function.body { + perry_hir::collect_local_refs_stmt(stmt, &mut refs, &mut visited); + } + refs.into_iter().all(|id| declared.contains(&id)) +} + +fn cross_function_expr_is_safe( + expr: &Expr, + allowed_ids: &HashSet, + extern_names: &mut Vec, +) -> bool { + match expr { + Expr::FuncRef(id) => allowed_ids.contains(id), + Expr::ExternFuncRef { name, .. } => { + extern_names.push(name.clone()); + true + } + Expr::GlobalGet(_) | Expr::GlobalSet(_, _) | Expr::NativeModuleRef(_) => false, + // Capture-free zero-argument closures are self-contained after their + // FuncId is refreshed in the destination. This admits default thunks + // such as `exists = () => true` without allowing a source local to + // leak across the module boundary. + Expr::Closure { + params, + body, + captures, + mutable_captures, + captures_this, + captures_new_target, + .. + } => { + params.is_empty() + && captures.is_empty() + && mutable_captures.is_empty() + && !captures_this + && !captures_new_target + && cross_function_stmts_are_safe(body, allowed_ids, extern_names) + } + other => { + let mut safe = true; + walk_expr_children(other, &mut |child| { + if !cross_function_expr_is_safe(child, allowed_ids, extern_names) { + safe = false; + } + }); + safe + } + } +} + +fn cross_function_stmts_are_safe( + stmts: &[Stmt], + allowed_ids: &HashSet, + extern_names: &mut Vec, +) -> bool { + let mut safe = true; + walk_stmts(stmts, &mut |expr| { + if !cross_function_expr_is_safe(expr, allowed_ids, extern_names) { + safe = false; + } + }); + safe +} + +fn recursive_stmt_count(stmts: &[Stmt]) -> usize { + fn count(stmt: &Stmt) -> usize { + 1 + match stmt { + Stmt::If { + then_branch, + else_branch, + .. + } => { + then_branch.iter().map(count).sum::() + + else_branch + .as_ref() + .map_or(0, |branch| branch.iter().map(count).sum()) + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => body.iter().map(count).sum(), + Stmt::For { init, body, .. } => { + init.as_ref().map_or(0, |stmt| count(stmt)) + body.iter().map(count).sum::() + } + Stmt::Switch { cases, .. } => cases.iter().flat_map(|case| &case.body).map(count).sum(), + Stmt::Try { + body, + catch, + finally, + } => { + body.iter().map(count).sum::() + + catch + .as_ref() + .map_or(0, |clause| clause.body.iter().map(count).sum()) + + finally + .as_ref() + .map_or(0, |body| body.iter().map(count).sum()) + } + Stmt::Labeled { body, .. } => count(body), + _ => 0, + } + } + stmts.iter().map(count).sum() +} + +fn walk_stmts(stmts: &[Stmt], visit: &mut impl FnMut(&Expr)) { + for stmt in stmts { + match stmt { + Stmt::Let { init, .. } => { + if let Some(expr) = init { + visit(expr); + } + } + Stmt::Expr(expr) | Stmt::Throw(expr) | Stmt::Return(Some(expr)) => visit(expr), + Stmt::Return(None) | Stmt::Break | Stmt::Continue => {} + Stmt::LabeledBreak(_) | Stmt::LabeledContinue(_) => {} + Stmt::If { + condition, + then_branch, + else_branch, + } => { + visit(condition); + walk_stmts(then_branch, visit); + if let Some(branch) = else_branch { + walk_stmts(branch, visit); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + visit(condition); + walk_stmts(body, visit); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(init) = init { + walk_stmts(std::slice::from_ref(init.as_ref()), visit); + } + if let Some(condition) = condition { + visit(condition); + } + if let Some(update) = update { + visit(update); + } + walk_stmts(body, visit); + } + Stmt::Switch { + discriminant, + cases, + } => { + visit(discriminant); + for case in cases { + if let Some(test) = &case.test { + visit(test); + } + walk_stmts(&case.body, visit); + } + } + Stmt::Try { + body, + catch, + finally, + } => { + walk_stmts(body, visit); + if let Some(catch) = catch { + walk_stmts(&catch.body, visit); + } + if let Some(finally) = finally { + walk_stmts(finally, visit); + } + } + Stmt::Labeled { body, .. } => walk_stmts(std::slice::from_ref(body.as_ref()), visit), + Stmt::PreallocateBoxes(_) | Stmt::PreallocateTdzBoxes(_) | Stmt::ReleaseBoxes(_) => {} + } + } +} + +/// Install imported function candidates as private destination-local +/// functions, then retarget only direct calls to those fresh functions. The +/// private clone is a correctness fallback: if the ordinary inliner declines +/// a call site, codegen still sees a valid destination FuncId rather than a +/// source-module ID. +pub(crate) fn localize_cross_module_functions( + module: &mut Module, + candidates: &HashMap<(String, String), FunctionCandidate>, +) { + if candidates.is_empty() { + return; + } + + type CandidateKey = (String, String); + + let mut used_locals: HashSet = HashSet::new(); + let mut binding_for_import: BTreeMap = BTreeMap::new(); + let mut aliases_by_candidate: BTreeMap> = BTreeMap::new(); + let mut selected = BTreeSet::new(); + let mut queue = VecDeque::new(); + + for import in &module.imports { + let Some(path) = &import.resolved_path else { + continue; + }; + for specifier in &import.specifiers { + match specifier { + ImportSpecifier::Named { imported, local } => { + used_locals.insert(local.clone()); + if import.type_only { + continue; + } + let key = (path.clone(), imported.clone()); + binding_for_import + .entry(key.clone()) + .or_insert_with(|| local.clone()); + if candidates.contains_key(&key) { + aliases_by_candidate + .entry(key.clone()) + .or_default() + .insert(local.clone()); + if selected.insert(key.clone()) { + queue.push_back(key); + } + } + } + ImportSpecifier::Default { local } | ImportSpecifier::Namespace { local } => { + used_locals.insert(local.clone()); + } + } + } + } + if selected.is_empty() { + return; + } + used_locals.extend( + module + .functions + .iter() + .map(|function| function.name.clone()), + ); + used_locals.extend(module.classes.iter().map(|class| class.name.clone())); + used_locals.extend(module.globals.iter().map(|global| global.name.clone())); + + let mut pending_imports: BTreeMap> = BTreeMap::new(); + let mut alias_counter = 0usize; + while let Some(key) = queue.pop_front() { + let Some(candidate) = candidates.get(&key) else { + continue; + }; + for required in &candidate.required_extern_imports { + let dependency_key = (required.resolved_path.clone(), required.imported.clone()); + let local = if let Some(local) = binding_for_import.get(&dependency_key) { + local.clone() + } else { + let local = loop { + let candidate_local = format!( + "__perry_xmod_{}_{}", + alias_counter, + sanitize_identifier(&required.local) + ); + alias_counter += 1; + if used_locals.insert(candidate_local.clone()) { + break candidate_local; + } + }; + binding_for_import.insert(dependency_key.clone(), local.clone()); + pending_imports + .entry(required.resolved_path.clone()) + .or_default() + .insert((required.imported.clone(), local.clone())); + local + }; + if candidates.contains_key(&dependency_key) + && selected.len() < MAX_LOCALIZED_FUNCTION_ROOTS + { + aliases_by_candidate + .entry(dependency_key.clone()) + .or_default() + .insert(local); + if selected.insert(dependency_key.clone()) { + queue.push_back(dependency_key); + } + } + } + } + + for (path, specifiers) in pending_imports { + if let Some(import) = module.imports.iter_mut().find(|import| { + !import.type_only && import.resolved_path.as_deref() == Some(path.as_str()) + }) { + for (imported, local) in specifiers { + if !import.specifiers.iter().any(|specifier| { + matches!( + specifier, + ImportSpecifier::Named { + imported: existing_imported, + local: existing_local, + } if existing_imported == &imported && existing_local == &local + ) + }) { + import + .specifiers + .push(ImportSpecifier::Named { imported, local }); + } + } + } else { + module.imports.push(perry_hir::Import { + source: path.clone(), + specifiers: specifiers + .into_iter() + .map(|(imported, local)| ImportSpecifier::Named { imported, local }) + .collect(), + is_native: false, + module_kind: perry_hir::ModuleKind::NativeCompiled, + resolved_path: Some(path), + type_only: false, + is_dynamic: false, + is_dynamic_target: false, + is_deferred_require: false, + is_adopted_require: false, + }); + } + } + + let mut next_func_id = crate::generator::compute_max_func_id(module).saturating_add(1); + let mut next_local_id = crate::generator::compute_max_local_id(module).saturating_add(1); + let mut call_rewrites: HashMap = HashMap::new(); + let mut localized_functions = Vec::new(); + + for key in selected { + let Some(candidate) = candidates.get(&key) else { + continue; + }; + let mut func_id_remap = HashMap::new(); + for function in &candidate.functions { + func_id_remap.insert(function.id, next_func_id); + next_func_id = next_func_id.saturating_add(1); + } + let Some(&localized_root_id) = func_id_remap.get(&candidate.root_id) else { + continue; + }; + + if let Some(aliases) = aliases_by_candidate.get(&key) { + for alias in aliases { + call_rewrites.insert(alias.clone(), localized_root_id); + } + } + + let extern_renames: HashMap = candidate + .required_extern_imports + .iter() + .filter_map(|required| { + let dependency_key = (required.resolved_path.clone(), required.imported.clone()); + binding_for_import + .get(&dependency_key) + .map(|local| (required.local.clone(), local.clone())) + }) + .collect(); + let mut closure_func_remap = HashMap::new(); + + for source_function in &candidate.functions { + let mut function = source_function.clone(); + let Some(&localized_id) = func_id_remap.get(&source_function.id) else { + continue; + }; + function.id = localized_id; + function.name = format!( + "__perry_xmod_inline_{}_{}", + localized_id, + sanitize_identifier(&source_function.name) + ); + function.is_exported = false; + + let mut local_remap: HashMap = HashMap::new(); + for param in &function.params { + local_remap.entry(param.id).or_insert_with(|| { + let fresh = next_local_id; + next_local_id = next_local_id.saturating_add(1); + Expr::LocalGet(fresh) + }); + } + for id in collect_body_local_ids(&function.body) { + local_remap.entry(id).or_insert_with(|| { + let fresh = next_local_id; + next_local_id = next_local_id.saturating_add(1); + Expr::LocalGet(fresh) + }); + } + for param in &mut function.params { + if let Some(Expr::LocalGet(fresh)) = local_remap.get(¶m.id) { + param.id = *fresh; + } + if let Some(default) = &mut param.default { + substitute_locals(default, &local_remap, &mut next_local_id); + rewrite_candidate_expr( + default, + &func_id_remap, + &extern_renames, + &mut closure_func_remap, + &mut next_func_id, + ); + } + } + substitute_locals_in_stmts(&mut function.body, &local_remap, &mut next_local_id); + rewrite_candidate_stmts( + &mut function.body, + &func_id_remap, + &extern_renames, + &mut closure_func_remap, + &mut next_func_id, + ); + localized_functions.push(function); + } + } + + module.functions.extend(localized_functions); + rewrite_direct_extern_calls_in_module(module, &call_rewrites); +} + +fn sanitize_identifier(name: &str) -> String { + let sanitized: String = name + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || character == '_' { + character + } else { + '_' + } + }) + .collect(); + if sanitized.is_empty() { + "fn".to_string() + } else { + sanitized + } +} + +fn rewrite_candidate_stmts( + stmts: &mut [Stmt], + func_id_remap: &HashMap, + extern_renames: &HashMap, + closure_func_remap: &mut HashMap, + next_func_id: &mut FuncId, +) { + walk_stmts_mut(stmts, &mut |expr| { + rewrite_candidate_expr( + expr, + func_id_remap, + extern_renames, + closure_func_remap, + next_func_id, + ) + }); +} + +fn rewrite_candidate_expr( + expr: &mut Expr, + func_id_remap: &HashMap, + extern_renames: &HashMap, + closure_func_remap: &mut HashMap, + next_func_id: &mut FuncId, +) { + match expr { + Expr::FuncRef(id) => { + if let Some(remapped) = func_id_remap.get(id) { + *id = *remapped; + } + return; + } + Expr::ExternFuncRef { name, .. } => { + if let Some(remapped) = extern_renames.get(name) { + *name = remapped.clone(); + } + return; + } + Expr::Closure { + func_id, + params, + body, + .. + } => { + let original = *func_id; + *func_id = *closure_func_remap.entry(original).or_insert_with(|| { + let fresh = *next_func_id; + *next_func_id = next_func_id.saturating_add(1); + fresh + }); + for param in params { + if let Some(default) = &mut param.default { + rewrite_candidate_expr( + default, + func_id_remap, + extern_renames, + closure_func_remap, + next_func_id, + ); + } + } + rewrite_candidate_stmts( + body, + func_id_remap, + extern_renames, + closure_func_remap, + next_func_id, + ); + return; + } + _ => {} + } + walk_expr_children_mut(expr, &mut |child| { + rewrite_candidate_expr( + child, + func_id_remap, + extern_renames, + closure_func_remap, + next_func_id, + ) + }); +} + +fn rewrite_direct_extern_calls_in_module( + module: &mut Module, + call_rewrites: &HashMap, +) { + let rewrite_body = |body: &mut [Stmt]| { + walk_stmts_mut(body, &mut |expr| { + rewrite_direct_extern_call(expr, call_rewrites) + }); + }; + rewrite_body(&mut module.init); + for function in &mut module.functions { + rewrite_body(&mut function.body); + } + for class in &mut module.classes { + if let Some(constructor) = &mut class.constructor { + rewrite_body(&mut constructor.body); + } + for method in &mut class.methods { + rewrite_body(&mut method.body); + } + for (_, getter) in &mut class.getters { + rewrite_body(&mut getter.body); + } + for (_, setter) in &mut class.setters { + rewrite_body(&mut setter.body); + } + for method in &mut class.static_methods { + rewrite_body(&mut method.body); + } + for member in &mut class.computed_members { + rewrite_body(&mut member.function.body); + } + } +} + +fn rewrite_direct_extern_call(expr: &mut Expr, call_rewrites: &HashMap) { + if let Expr::Call { callee, .. } = expr { + if let Expr::ExternFuncRef { name, .. } = callee.as_ref() { + if let Some(id) = call_rewrites.get(name) { + **callee = Expr::FuncRef(*id); + } + } + } + if let Expr::Closure { body, .. } = expr { + walk_stmts_mut(body, &mut |child| { + rewrite_direct_extern_call(child, call_rewrites) + }); + } + walk_expr_children_mut(expr, &mut |child| { + rewrite_direct_extern_call(child, call_rewrites) + }); +} + +fn walk_stmts_mut(stmts: &mut [Stmt], visit: &mut impl FnMut(&mut Expr)) { + for stmt in stmts { + match stmt { + Stmt::Let { init, .. } => { + if let Some(expr) = init { + visit(expr); + } + } + Stmt::Expr(expr) | Stmt::Throw(expr) | Stmt::Return(Some(expr)) => visit(expr), + Stmt::Return(None) | Stmt::Break | Stmt::Continue => {} + Stmt::LabeledBreak(_) | Stmt::LabeledContinue(_) => {} + Stmt::If { + condition, + then_branch, + else_branch, + } => { + visit(condition); + walk_stmts_mut(then_branch, visit); + if let Some(branch) = else_branch { + walk_stmts_mut(branch, visit); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + visit(condition); + walk_stmts_mut(body, visit); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(init) = init { + walk_stmts_mut(std::slice::from_mut(init.as_mut()), visit); + } + if let Some(condition) = condition { + visit(condition); + } + if let Some(update) = update { + visit(update); + } + walk_stmts_mut(body, visit); + } + Stmt::Switch { + discriminant, + cases, + } => { + visit(discriminant); + for case in cases { + if let Some(test) = &mut case.test { + visit(test); + } + walk_stmts_mut(&mut case.body, visit); + } + } + Stmt::Try { + body, + catch, + finally, + } => { + walk_stmts_mut(body, visit); + if let Some(catch) = catch { + walk_stmts_mut(&mut catch.body, visit); + } + if let Some(finally) = finally { + walk_stmts_mut(finally, visit); + } + } + Stmt::Labeled { body, .. } => { + walk_stmts_mut(std::slice::from_mut(body.as_mut()), visit) + } + Stmt::PreallocateBoxes(_) | Stmt::PreallocateTdzBoxes(_) | Stmt::ReleaseBoxes(_) => {} + } + } +} + pub fn gather_cross_module_methods(module: &Module) -> HashMap<(String, String), MethodCandidate> { let mut out: HashMap<(String, String), MethodCandidate> = HashMap::new(); let nonexported = collect_nonexported_class_names(module); diff --git a/crates/perry-transform/src/inline/mod.rs b/crates/perry-transform/src/inline/mod.rs index 437e0cb703..960908068d 100644 --- a/crates/perry-transform/src/inline/mod.rs +++ b/crates/perry-transform/src/inline/mod.rs @@ -21,7 +21,7 @@ mod super_detect; // Public re-exports (explicit named — globs don't propagate transitively // through `pub(crate) use crate::inline::*` consumers). pub use cross_module::{ - gather_cross_module_anon_classes, gather_cross_module_methods, + gather_cross_module_anon_classes, gather_cross_module_functions, gather_cross_module_methods, gather_cross_module_methods_with_extern_imports, is_cross_module_safe, }; @@ -81,6 +81,29 @@ pub struct MethodCandidate { pub required_extern_imports: Vec<(String, String)>, } +/// One named import used by a cross-module function candidate. +/// +/// Keeping both names matters for aliased imports: the candidate body refers +/// to `local`, while a destination module must import `imported` from the +/// resolved source module (possibly under a fresh collision-free alias). +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct RequiredExternImport { + pub local: String, + pub imported: String, + pub resolved_path: String, +} + +/// A small exported function plus the bounded same-module helper graph it +/// needs. The graph is localized into an importing module under fresh IDs, +/// which gives the ordinary inliner a valid fallback call if a particular +/// call site cannot be expanded. +#[derive(Clone, Debug)] +pub struct FunctionCandidate { + pub root_id: FuncId, + pub functions: Vec, + pub required_extern_imports: Vec, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ExactReceiverFact { pub(crate) class_name: String, @@ -101,9 +124,11 @@ pub(crate) type ExactReceiverFacts = HashMap; pub fn inline_functions( module: &mut Module, extra_methods: &HashMap<(String, String), MethodCandidate>, + extra_functions: &HashMap<(String, String), FunctionCandidate>, extra_class_fields: &HashMap<(String, String), String>, extra_anon_classes: &HashMap, ) { + cross_module::localize_cross_module_functions(module, extra_functions); let first_fresh_id = find_max_local_id_in_module(module).saturating_add(1); let span_remaps = crate::source_spans::RemapSession::start(first_fresh_id); inline_functions_inner( @@ -374,6 +399,14 @@ fn inline_functions_inner( for cand in extra_methods.values() { collect_anon_refs(&cand.func.body, &mut needed); } + // Cross-module free-function candidates have already been localized + // into `module.functions` before this pass. Scan the destination's + // function bodies as well so any content-addressed result-record + // shapes referenced by those private clones receive their class + // definitions before codegen. + for function in &module.functions { + collect_anon_refs(&function.body, &mut needed); + } let already_present: HashSet = module.classes.iter().map(|c| c.name.clone()).collect(); // Anon-shape ctor params + body Lets are minted by the SOURCE @@ -762,13 +795,24 @@ fn inline_functions_inner( // — without inlining each call goes through `js_native_call_method` // dispatch + heap-allocates the returned `{entityId, componentType, // component}` literal. + // A method that is itself an inline candidate must not see the method + // candidate map while its own body is optimized: a direct or mutual + // method call could recursively expand without a stable fixed point. + // Standalone functions already use the ordinary self-recursion rejection + // and bounded nested-inline machinery, and skipping them used to leave + // ordinary calls inside small candidate methods permanently outlined. + // That is particularly costly when the helper returns an anonymous record + // which the later aggregate-scalar pass could otherwise eliminate. + let no_method_candidates: HashMap<(String, String), MethodCandidate> = HashMap::new(); for class in &mut module.classes { let class_name = class.name.clone(); for method in &mut class.methods { - // Skip if this method is itself a candidate (avoid recursion) - if method_candidates.contains_key(&(class_name.clone(), method.name.clone())) { - continue; - } + let method_candidates_for_body = + if method_candidates.contains_key(&(class_name.clone(), method.name.clone())) { + &no_method_candidates + } else { + &method_candidates + }; let mut local_id = next_module_id; let mut local_types: HashMap = HashMap::new(); let mut exact_receiver_facts = ExactReceiverFacts::new(); @@ -780,7 +824,7 @@ fn inline_functions_inner( inline_calls_in_stmts( &mut method.body, &func_candidates, - &method_candidates, + method_candidates_for_body, &class_names, &mut local_types, &mut exact_receiver_facts, @@ -980,6 +1024,7 @@ mod tests { &extra_methods, &HashMap::new(), &HashMap::new(), + &HashMap::new(), ); let sources: Vec<&str> = module.imports.iter().map(|i| i.source.as_str()).collect(); @@ -1026,6 +1071,7 @@ mod tests { &mut module, &extra_methods, &HashMap::new(), + &HashMap::new(), &extra_anon_classes, ); @@ -1033,6 +1079,397 @@ mod tests { assert_eq!(class_names, vec!["__AnonShape_aaa", "__AnonShape_bbb"]); } + #[test] + fn cross_module_free_function_graph_is_localized_and_inlined() { + let mut source = Module::new("/src/ops.ts"); + source.imports.push(perry_hir::Import { + source: "./predicate".to_string(), + specifiers: vec![ImportSpecifier::Named { + imported: "predicate".to_string(), + local: "sourcePredicate".to_string(), + }], + is_native: false, + module_kind: ModuleKind::NativeCompiled, + resolved_path: Some("/src/predicate.ts".to_string()), + type_only: false, + is_dynamic: false, + is_dynamic_target: false, + is_deferred_require: false, + is_adopted_require: false, + }); + let helper = function( + 1, + vec![Stmt::Return(Some(Expr::Call { + callee: Box::new(Expr::ExternFuncRef { + name: "sourcePredicate".to_string(), + param_types: Vec::new(), + return_type: Type::Any, + }), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }))], + ); + let mut root = function( + 2, + vec![Stmt::Return(Some(Expr::Call { + callee: Box::new(Expr::FuncRef(1)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }))], + ); + root.name = "operation".to_string(); + root.is_exported = true; + source.functions.extend([helper, root]); + source.exported_functions.push(("operation".to_string(), 2)); + + let gathered = gather_cross_module_functions(&source); + let candidate = gathered + .get("operation") + .expect("exported helper graph should be harvested") + .clone(); + assert_eq!(candidate.functions.len(), 2); + assert_eq!( + candidate.required_extern_imports, + vec![RequiredExternImport { + local: "sourcePredicate".to_string(), + imported: "predicate".to_string(), + resolved_path: "/src/predicate.ts".to_string(), + }] + ); + + let mut destination = Module::new("/src/main.ts"); + destination.imports.push(perry_hir::Import { + source: "./ops".to_string(), + specifiers: vec![ImportSpecifier::Named { + imported: "operation".to_string(), + local: "runOperation".to_string(), + }], + is_native: false, + module_kind: ModuleKind::NativeCompiled, + resolved_path: Some("/src/ops.ts".to_string()), + type_only: false, + is_dynamic: false, + is_dynamic_target: false, + is_deferred_require: false, + is_adopted_require: false, + }); + destination.imports.push(perry_hir::Import { + source: "./predicate".to_string(), + specifiers: vec![ImportSpecifier::Named { + imported: "predicate".to_string(), + local: "destinationPredicate".to_string(), + }], + is_native: false, + module_kind: ModuleKind::NativeCompiled, + resolved_path: Some("/src/predicate.ts".to_string()), + type_only: false, + is_dynamic: false, + is_dynamic_target: false, + is_deferred_require: false, + is_adopted_require: false, + }); + let call = Stmt::Let { + id: 1, + name: "result".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Call { + callee: Box::new(Expr::ExternFuncRef { + name: "runOperation".to_string(), + param_types: Vec::new(), + return_type: Type::Any, + }), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }), + }; + let mut caller_body = vec![Stmt::Expr(Expr::Integer(0)); MAX_INLINE_STMTS]; + caller_body.push(call); + let mut caller = function(50, caller_body); + caller.name = "caller".to_string(); + destination.functions.push(caller); + let mut extra_functions = HashMap::new(); + extra_functions.insert( + ("/src/ops.ts".to_string(), "operation".to_string()), + candidate, + ); + + inline_functions( + &mut destination, + &HashMap::new(), + &extra_functions, + &HashMap::new(), + &HashMap::new(), + ); + + let dump = format!("{:?}", destination.functions[0].body); + assert!( + !dump.contains("runOperation"), + "root call was not localized: {dump}" + ); + assert!( + !dump.contains("callee: FuncRef("), + "helper call was not inlined: {dump}" + ); + assert!( + dump.contains("destinationPredicate"), + "source import alias was not rebound to the destination: {dump}" + ); + } + + #[test] + fn cross_module_free_function_cycle_is_rejected() { + let mut source = Module::new("/src/cycle.ts"); + let call = |id| { + vec![Stmt::Return(Some(Expr::Call { + callee: Box::new(Expr::FuncRef(id)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }))] + }; + let mut first = function(1, call(2)); + first.is_exported = true; + source.functions.extend([first, function(2, call(1))]); + source.exported_functions.push(("first".to_string(), 1)); + + assert!(gather_cross_module_functions(&source).is_empty()); + } + + #[test] + fn cross_module_free_function_with_module_local_is_rejected() { + let mut source = Module::new("/src/constants.ts"); + let mut reads_module_binding = function(1, vec![Stmt::Return(Some(Expr::LocalGet(99)))]); + reads_module_binding.name = "withinLimit".to_string(); + reads_module_binding.is_exported = true; + source.functions.push(reads_module_binding); + source + .exported_functions + .push(("withinLimit".to_string(), 1)); + + assert!(gather_cross_module_functions(&source).is_empty()); + } + + #[test] + fn cross_module_runtime_import_does_not_reuse_type_only_import() { + let mut root = function( + 1, + vec![Stmt::Return(Some(Expr::Call { + callee: Box::new(Expr::ExternFuncRef { + name: "sourcePredicate".to_string(), + param_types: Vec::new(), + return_type: Type::Any, + }), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }))], + ); + root.name = "operation".to_string(); + let candidate = FunctionCandidate { + root_id: 1, + functions: vec![root], + required_extern_imports: vec![RequiredExternImport { + local: "sourcePredicate".to_string(), + imported: "predicate".to_string(), + resolved_path: "/src/predicate.ts".to_string(), + }], + }; + + let mut destination = Module::new("/src/main.ts"); + destination.imports.push(perry_hir::Import { + source: "./ops".to_string(), + specifiers: vec![ImportSpecifier::Named { + imported: "operation".to_string(), + local: "runOperation".to_string(), + }], + is_native: false, + module_kind: ModuleKind::NativeCompiled, + resolved_path: Some("/src/ops.ts".to_string()), + type_only: false, + is_dynamic: false, + is_dynamic_target: false, + is_deferred_require: false, + is_adopted_require: false, + }); + destination.imports.push(perry_hir::Import { + source: "./predicate".to_string(), + specifiers: vec![ImportSpecifier::Named { + imported: "Predicate".to_string(), + local: "Predicate".to_string(), + }], + is_native: false, + module_kind: ModuleKind::NativeCompiled, + resolved_path: Some("/src/predicate.ts".to_string()), + type_only: true, + is_dynamic: false, + is_dynamic_target: false, + is_deferred_require: false, + is_adopted_require: false, + }); + let mut candidates = HashMap::new(); + candidates.insert( + ("/src/ops.ts".to_string(), "operation".to_string()), + candidate, + ); + + cross_module::localize_cross_module_functions(&mut destination, &candidates); + + let predicate_imports: Vec<_> = destination + .imports + .iter() + .filter(|import| import.resolved_path.as_deref() == Some("/src/predicate.ts")) + .collect(); + assert_eq!(predicate_imports.len(), 2); + assert!(predicate_imports.iter().any(|import| import.type_only + && import.specifiers.len() == 1 + && matches!( + &import.specifiers[0], + ImportSpecifier::Named { imported, local } + if imported == "Predicate" && local == "Predicate" + ))); + assert!(predicate_imports.iter().any(|import| !import.type_only + && import.specifiers.iter().any(|specifier| { + matches!(specifier, ImportSpecifier::Named { imported, .. } if imported == "predicate") + }))); + } + + #[test] + fn inlines_record_call_hidden_by_destructure_coercion() { + let record = |value| Expr::New { + class_name: "__AnonShape_result".to_string(), + args: vec![Expr::Integer(value)], + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }; + let mut resolver = function( + 1, + vec![ + Stmt::If { + condition: Expr::Bool(false), + then_branch: vec![Stmt::Return(Some(record(1)))], + else_branch: None, + }, + Stmt::Return(Some(record(2))), + ], + ); + resolver.name = "resolve".to_string(); + + let wrapped_call = Expr::NativeMethodCall { + module: "__perry_runtime".to_string(), + class_name: None, + object: None, + method: "requireObjectCoercible".to_string(), + args: vec![ + Expr::Call { + callee: Box::new(Expr::FuncRef(1)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }, + Expr::Number(10.0), + ], + }; + let mut method_body = vec![Stmt::Expr(Expr::Integer(0)); MAX_INLINE_STMTS + 1]; + method_body.push(Stmt::Let { + id: 1, + name: "__destruct_1".to_string(), + ty: Type::Any, + mutable: false, + init: Some(wrapped_call), + }); + let mut method = function(2, method_body); + method.name = "consume".to_string(); + let mut class = anon_class(1, "Consumer"); + class.methods.push(method); + let mut module = Module::new("destructure-inline.ts"); + module.functions.push(resolver); + module.classes.push(class); + + inline_functions( + &mut module, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ); + + let dump = format!("{:?}", module.classes[0].methods[0].body); + assert!(!dump.contains("requireObjectCoercible"), "{dump}"); + assert!(!dump.contains("FuncRef(1)"), "{dump}"); + assert!(dump.contains("DoWhile"), "{dump}"); + } + + #[test] + fn candidate_method_still_inlines_standalone_record_helper() { + let record = |value| Expr::New { + class_name: "__AnonShape_result".to_string(), + args: vec![Expr::Integer(value)], + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }; + let mut resolver = function( + 1, + vec![ + Stmt::If { + condition: Expr::Bool(false), + then_branch: vec![Stmt::Return(Some(record(1)))], + else_branch: None, + }, + Stmt::Return(Some(record(2))), + ], + ); + resolver.name = "resolve".to_string(); + + let mut method = function( + 2, + vec![ + Stmt::Let { + id: 10, + name: "result".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Call { + callee: Box::new(Expr::FuncRef(1)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }), + }, + Stmt::Return(Some(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(10)), + property: "type".to_string(), + byte_offset: 0, + })), + ], + ); + method.name = "exists".to_string(); + assert!(is_inlinable_method(&method)); + + let mut class = anon_class(1, "Store"); + class.methods.push(method); + let mut module = Module::new("candidate-method-helper.ts"); + module.functions.push(resolver); + module.classes.push(class); + + inline_functions( + &mut module, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ); + + let dump = format!("{:?}", module.classes[0].methods[0].body); + assert!(!dump.contains("FuncRef(1)"), "{dump}"); + assert!(dump.contains("DoWhile"), "{dump}"); + } + #[test] fn inlined_body_local_keeps_its_source_span() { let original_id = 7; @@ -1062,6 +1499,7 @@ mod tests { &HashMap::new(), &HashMap::new(), &HashMap::new(), + &HashMap::new(), ); let cloned_id = module diff --git a/crates/perry-transform/src/lib.rs b/crates/perry-transform/src/lib.rs index 6ce199f539..5d64b6b518 100644 --- a/crates/perry-transform/src/lib.rs +++ b/crates/perry-transform/src/lib.rs @@ -26,8 +26,9 @@ pub use finally_inline::inline_finally_into_returns; pub use generator::transform_generators; pub use i18n::{apply_i18n, I18nDiagnostic, I18nStringTable}; pub use inline::{ - gather_cross_module_anon_classes, gather_cross_module_methods, - gather_cross_module_methods_with_extern_imports, inline_functions, MethodCandidate, + gather_cross_module_anon_classes, gather_cross_module_functions, gather_cross_module_methods, + gather_cross_module_methods_with_extern_imports, inline_functions, FunctionCandidate, + MethodCandidate, RequiredExternImport, }; pub use unroll::unroll_static_loops; diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 558729dfea..31a1ac1cd9 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -7,9 +7,9 @@ use anyhow::{anyhow, Result}; use perry_hir::ModuleKind; use perry_transform::{ - gather_cross_module_anon_classes, gather_cross_module_methods, + gather_cross_module_anon_classes, gather_cross_module_functions, gather_cross_module_methods, gather_cross_module_methods_with_extern_imports, inline_finally_into_returns, inline_functions, - transform_async_to_generator, transform_generators, MethodCandidate, + transform_async_to_generator, transform_generators, FunctionCandidate, MethodCandidate, }; use std::collections::HashSet; use std::fs; diff --git a/crates/perry/src/commands/compile/collect_modules/finish.rs b/crates/perry/src/commands/compile/collect_modules/finish.rs index b0673ff223..f6fee1433d 100644 --- a/crates/perry/src/commands/compile/collect_modules/finish.rs +++ b/crates/perry/src/commands/compile/collect_modules/finish.rs @@ -49,6 +49,8 @@ pub(crate) fn collect_module_finish( }); let mut extra_methods: std::collections::HashMap<(String, String), MethodCandidate> = std::collections::HashMap::new(); + let mut extra_functions: std::collections::HashMap<(String, String), FunctionCandidate> = + std::collections::HashMap::new(); if std::env::var("PERRY_INLINE_DEBUG").is_ok() { eprintln!( "[INLINE-DRIVER] processing {}: prior modules={:?}", @@ -70,7 +72,7 @@ pub(crate) fn collect_module_finish( ); } if enable_cross_module_inline { - for prior_module in ctx.native_modules.values() { + for (prior_path, prior_module) in &ctx.native_modules { // The strict harvester rejects ExternFuncRef-using methods. // The loose variant records each required extern name; // `inline_functions` filters by destination imports. @@ -84,6 +86,88 @@ pub(crate) fn collect_module_finish( for (k, v) in gather_cross_module_methods(prior_module) { extra_methods.entry(k).or_insert(v); } + let source_path = prior_path.to_string_lossy().into_owned(); + for (exported, candidate) in gather_cross_module_functions(prior_module) { + extra_functions + .entry((source_path.clone(), exported)) + .or_insert(candidate); + } + } + + // Publish candidates through ordinary re-export barrels. A HIR + // ReExport/ExportAll edge is not also an Import entry, so resolve + // its source with the compile driver's canonical resolver (the + // same path identity used by module collection and codegen). + // A bounded fixpoint mirrors ESM's transitive named/export-star + // surface without weakening identity matching to a bare name. + let reexport_surfaces: Vec<_> = ctx + .native_modules + .iter() + .map(|(path, module)| { + ( + path.clone(), + path.to_string_lossy().into_owned(), + module.exports.clone(), + ) + }) + .collect(); + for _ in 0..=reexport_surfaces.len() { + let mut additions = Vec::new(); + for (prior_path, barrel_path, exports) in &reexport_surfaces { + for export in exports { + match export { + perry_hir::Export::ReExport { + source, + imported, + exported, + } => { + let Some((resolved, perry_hir::ModuleKind::NativeCompiled)) = + cached_resolve_import(source, prior_path, ctx) + else { + continue; + }; + let resolved = resolved.to_string_lossy().into_owned(); + if let Some(candidate) = + extra_functions.get(&(resolved, imported.clone())) + { + additions.push(( + (barrel_path.clone(), exported.clone()), + candidate.clone(), + )); + } + } + perry_hir::Export::ExportAll { source } => { + let Some((resolved, perry_hir::ModuleKind::NativeCompiled)) = + cached_resolve_import(source, prior_path, ctx) + else { + continue; + }; + let resolved = resolved.to_string_lossy().into_owned(); + for ((candidate_path, exported), candidate) in &extra_functions { + if candidate_path == &resolved && exported != "default" { + additions.push(( + (barrel_path.clone(), exported.clone()), + candidate.clone(), + )); + } + } + } + _ => {} + } + } + } + let mut changed = false; + for (key, candidate) in additions { + if let std::collections::hash_map::Entry::Vacant(entry) = + extra_functions.entry(key) + { + entry.insert(candidate); + changed = true; + } + } + if !changed { + break; + } } } // Cross-module field-type info: `(class_name, field_name) -> @@ -151,6 +235,7 @@ pub(crate) fn collect_module_finish( inline_functions( &mut hir_module, &extra_methods, + &extra_functions, &extra_class_fields, &extra_anon_classes, ); diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 8fa35a76a0..291261c7e4 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -615,6 +615,14 @@ fn compute_object_cache_key_with_env( .collect::>() .join(","), ); + buf.push_str(":method_arguments_length_only="); + buf.push_str( + &c.method_arguments_length_only + .iter() + .map(|b| if *b { "1" } else { "0" }) + .collect::>() + .join(","), + ); buf.push_str(":static_fields="); buf.push_str(&c.static_field_names.join(",")); buf.push_str(":static_methods="); diff --git a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs index 7707df8e99..9aea3708c9 100644 --- a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs +++ b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs @@ -360,6 +360,7 @@ fn key_stable_for_nested_type_hashmap_order() { method_param_counts: vec![], method_has_rest: vec![], method_has_synthetic_arguments: vec![], + method_arguments_length_only: vec![], static_method_names: vec![], static_method_return_types: vec![], static_method_param_counts: vec![], @@ -419,6 +420,7 @@ fn key_changes_with_imported_class_signature() { method_param_counts: vec![0], method_has_rest: vec![false], method_has_synthetic_arguments: vec![false], + method_arguments_length_only: vec![false], static_method_names: vec![], static_method_return_types: vec![], static_method_param_counts: vec![], @@ -451,6 +453,7 @@ fn key_changes_with_imported_class_signature() { method_param_counts: vec![0], method_has_rest: vec![false], method_has_synthetic_arguments: vec![false], + method_arguments_length_only: vec![false], static_method_names: vec![], static_method_return_types: vec![], static_method_param_counts: vec![], @@ -491,6 +494,7 @@ fn key_changes_with_imported_class_codegen_surface() { method_param_counts: vec![1], method_has_rest: vec![false], method_has_synthetic_arguments: vec![false], + method_arguments_length_only: vec![false], static_method_names: vec!["make".into()], static_method_return_types: vec![perry_hir::types::Type::Number], static_method_param_counts: vec![1], @@ -531,6 +535,10 @@ fn key_changes_with_imported_class_codegen_surface() { changed.method_has_synthetic_arguments = vec![true]; assert_ne!(base_key, key_for(changed)); + let mut changed = base.clone(); + changed.method_arguments_length_only = vec![true]; + assert_ne!(base_key, key_for(changed)); + let mut changed = base.clone(); changed.proven_this_method_names = vec!["bar".into()]; assert_ne!(base_key, key_for(changed)); diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 0b385a4134..e7078ff2e9 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -271,6 +271,11 @@ fn imported_class_from_hir( .is_some_and(|param| param.arguments_object.is_some()) }) .collect(), + method_arguments_length_only: class + .methods + .iter() + .map(perry_codegen::method_supports_arguments_length_direct_abi) + .collect(), static_field_names: class .static_fields .iter() @@ -371,6 +376,7 @@ fn imported_object_literal_from_capability( method_param_counts: Vec::new(), method_has_rest: Vec::new(), method_has_synthetic_arguments: Vec::new(), + method_arguments_length_only: Vec::new(), static_field_names: Vec::new(), static_method_names: Vec::new(), static_method_return_types: Vec::new(), diff --git a/scripts/gc_store_site_inventory.py b/scripts/gc_store_site_inventory.py index 30307058e9..700af8ba48 100644 --- a/scripts/gc_store_site_inventory.py +++ b/scripts/gc_store_site_inventory.py @@ -554,7 +554,10 @@ def scan_file(path: Path) -> list[Finding]: # barrier arm every census stem exercises. CODEGEN_BARRIERED_BINDINGS = { "crates/perry-codegen/src/expr/write_barrier.rs": ("*", 2), - "crates/perry-codegen/src/expr/array_push.rs": ("apush", 1), + # Two markers: the original generation-tested push store and, since #8872, + # the unconditional element store inside `emit_dynamic_pointer_push_store`, + # which the same `apush`-stem caller barriers after its layout bookkeeping. + "crates/perry-codegen/src/expr/array_push.rs": ("apush", 2), } RUNTIME_MARKER_RE = re.compile(r"GC_STORE_AUDIT\((BARRIERED|EXTERNAL_BARRIERED)\)") @@ -579,6 +582,9 @@ def scan_file(path: Path) -> list[Finding]: "array/header.rs: layout note + born-old barrier (fresh/suppressed sites)" ), "store_array_slot": "array/header.rs: canonicalize + runtime_store_jsvalue_slot", + "store_array_slot_resolved": ( + "array/header_gc_slots.rs: resolved-head store + layout note + runtime_write_barrier_slot" + ), "rebuild_array_layout": "array/header.rs: post-hoc bulk funnel; replays slot barriers", "rebuild_array_layout_exact": "array/header.rs: exact rebuild after bulk copy", "rebuild_array_layout_from_slots": "object/gc_slots.rs: rebuild from slot table", @@ -1423,7 +1429,8 @@ def synthetic_tree() -> dict[str, str]: "}\n" ), "crates/perry-codegen/src/expr/array_push.rs": ( - "// GC_STORE_AUDIT(BARRIERED): planted\n" + codegen_calls + "// GC_STORE_AUDIT(BARRIERED): planted\n" + "// GC_STORE_AUDIT(BARRIERED): planted store\n" + codegen_calls ), STEM_REGISTRY_PATH: ( "pub(super) const VERIFIED_BARRIER_STEMS: &[(&str, StemKind)] = &[\n" diff --git a/scripts/local_binding_type_allowlist.json b/scripts/local_binding_type_allowlist.json index 3626bbbac1..f251731282 100644 --- a/scripts/local_binding_type_allowlist.json +++ b/scripts/local_binding_type_allowlist.json @@ -177,6 +177,14 @@ "classification": "representation-proven", "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." }, + { + "path": "crates/perry-codegen/src/expr/property_get.rs", + "function": "lower", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "The hint only recognizes the compiler-private synthetic arguments-length marker type; that binding exists solely in direct-call-only clones whose caller materialized the boxed actual-argument count, and the public method retains ordinary Arguments semantics." + }, { "path": "crates/perry-codegen/src/expr/property_set.rs", "function": "guarded_declared_class_store_candidate",