From 6a68beb593f0ff9e3534d0666cd471fe28ab9542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 19:18:52 +0200 Subject: [PATCH 1/4] perf: complete ECS benchmark specializations --- .../src/codegen/argument_shape_clone_tests.rs | 132 +++++- crates/perry-codegen/src/codegen/closure.rs | 2 + .../src/codegen/emission_order_tests.rs | 2 + crates/perry-codegen/src/codegen/entry.rs | 4 + .../perry-codegen/src/codegen/entry/tests.rs | 2 + crates/perry-codegen/src/codegen/function.rs | 2 + crates/perry-codegen/src/codegen/helpers.rs | 2 +- crates/perry-codegen/src/codegen/method.rs | 4 + crates/perry-codegen/src/codegen/mod.rs | 128 +++++- .../src/codegen/number_exactness_tests.rs | 2 + crates/perry-codegen/src/codegen/opts.rs | 54 ++- .../perry-codegen/src/collectors/hir_facts.rs | 28 ++ .../src/collectors/object_literal_exports.rs | 140 +++++-- .../src/collectors/proven_args.rs | 96 +++-- .../collectors/proven_this_routing_tests.rs | 2 + .../perry-codegen/src/collectors/ptr_shape.rs | 115 +++++- .../src/collectors/ptr_shape_report.rs | 10 +- .../src/collectors/scalar_method_dispatch.rs | 60 ++- .../src/expr/array_push_guard_tests.rs | 2 + .../src/expr/call_spread_short.rs | 171 ++++++-- .../src/expr/call_spread_short_tests.rs | 60 ++- .../src/expr/class_field_barrier_tests.rs | 2 + .../class_method_arguments_object_tests.rs | 2 + .../src/expr/conforming_layout_note_tests.rs | 2 + crates/perry-codegen/src/expr/mod.rs | 31 +- .../src/expr/property_get/tests.rs | 2 + crates/perry-codegen/src/gc_call_effects.rs | 17 + crates/perry-codegen/src/lib.rs | 7 +- .../src/lower_call/alloc_hot_tests.rs | 2 + .../src/lower_call/method_override.rs | 67 +++- .../src/lower_call/property_get.rs | 10 + .../property_get/imported_object.rs | 378 ++++++++++++++---- .../src/lower_call/typed_shape_bake_tests.rs | 2 + .../src/native_root_coverage/mod.rs | 2 + crates/perry-codegen/src/root_reload.rs | 2 + .../src/runtime_decls/objects.rs | 6 + .../src/runtime_decls/strings.rs | 8 + .../src/stmt/boxed_slot_no_root_tests.rs | 2 + .../src/stmt/class_field_loop_tests.rs | 2 + .../src/stmt/element_shape_loop_tests.rs | 2 + .../src/stmt/prealloc_module_global_tests.rs | 2 + .../src/stmt/stable_packed_loop.rs | 109 ++++- .../src/temp_root_coverage/mod.rs | 2 + .../src/type_analysis/numeric/tests.rs | 2 + .../tests/app_window_config_options.rs | 2 + .../tests/argless_builtin_extra_args.rs | 2 + .../tests/class_field_store_pointer_test.rs | 2 + .../perry-codegen/tests/class_keys_gc_root.rs | 2 + .../tests/constructor_recursion.rs | 2 + .../tests/destructure_call_location.rs | 2 + .../tests/i64_spec_ternary_recursion.rs | 2 + .../tests/ios_platform_api_lowering.rs | 2 + .../tests/large_object_barriers.rs | 2 + .../tests/loop_safepoint_purity.rs | 2 + .../tests/macos_bundle_chdir_gate.rs | 2 + .../tests/native_proof_buffer_views.rs | 2 + .../tests/native_proof_regressions.rs | 2 + .../tests/node_test_mock_property_presence.rs | 2 + .../tests/perry_builtin_name_collision.rs | 2 + .../tests/release_boxes_lowering.rs | 2 + .../tests/scalar_replaced_slot_roots.rs | 2 + .../tests/shadow_slot_hygiene.rs | 2 + .../tests/static_symbol_hygiene.rs | 2 + .../tests/temp_root_operand_temporaries.rs | 2 + crates/perry-codegen/tests/typed_feedback.rs | 2 + .../typed_shape_declared_at_allocation.rs | 2 + .../tests/typed_shape_descriptor.rs | 2 + .../tests/typed_shape_descriptors.rs | 2 + .../src/lower/expr_call/module_static.rs | 27 +- .../expr_call/native_module_spread_tests.rs | 19 + crates/perry-runtime/src/array/flat_clone.rs | 12 + crates/perry-runtime/src/array/mod.rs | 2 +- .../src/array/spread_dense_tests.rs | 15 +- crates/perry-runtime/src/array/subclass.rs | 230 ++++++++++- .../src/object/native_call_method.rs | 18 +- crates/perry-runtime/src/typed_feedback.rs | 1 + .../src/typed_feedback/guards.rs | 132 ++++++ .../perry-runtime/src/typed_feedback/tests.rs | 140 +++++++ .../src/commands/compile/object_cache.rs | 81 +++- .../object_cache/object_cache_tests.rs | 3 + .../src/commands/compile/run_pipeline.rs | 75 ++++ .../tests/issue_8772_short_packed_spread.rs | 84 +++- ...issue_8773_closure_capture_packed_loops.rs | 67 +++- .../tests/issue_8774_argument_shape_clones.rs | 14 +- ...sue_8775_imported_object_specialization.rs | 48 ++- scripts/gc_root_dominance_check.py | 3 +- .../issue_8772_short_packed_spread/generic.ts | 7 + .../issue_8772_short_packed_spread/reverse.ts | 30 ++ .../issue_8774_argument_shapes/main.ts | 3 +- .../issue_8775_imported_object/main.js | 6 +- .../issue_8775_imported_object/runner.js | 10 + 91 files changed, 2475 insertions(+), 279 deletions(-) create mode 100644 test-files/fixtures/issue_8772_short_packed_spread/generic.ts create mode 100644 test-files/fixtures/issue_8772_short_packed_spread/reverse.ts create mode 100644 test-files/fixtures/issue_8775_imported_object/runner.js diff --git a/crates/perry-codegen/src/codegen/argument_shape_clone_tests.rs b/crates/perry-codegen/src/codegen/argument_shape_clone_tests.rs index 59c561daec..bffbfe61ba 100644 --- a/crates/perry-codegen/src/codegen/argument_shape_clone_tests.rs +++ b/crates/perry-codegen/src/codegen/argument_shape_clone_tests.rs @@ -187,12 +187,12 @@ fn guarded_call_routes_to_shadow_rooted_direct_field_clone() { assert!( ir.contains(&format!("call double @{clone_name}(")), - "the guarded call site must route to the argument clone:\n{ir}" + "the exact contained call site must route to the argument clone:\n{ir}" ); assert!( - ir.contains("pshape_arg.fallback") + !ir.contains("pshape_arg.fallback") && ir.contains("call double @perry_method_argument_shape_clone_ts__Registry__read("), - "guard failure must retain the ordinary method body:\n{ir}" + "fresh provenance must elide the redundant argument guard while other receiver routes retain the ordinary body:\n{ir}" ); assert!( clone.contains("@js_shadow_slot_bind(") @@ -302,3 +302,129 @@ fn aliased_or_reassigned_parameter_does_not_get_a_clone() { "a reassigned parameter must keep only generic semantics:\n{ir}" ); } + +fn define_property(target: Expr) -> Stmt { + Stmt::Expr(Expr::ObjectDefineProperty( + Box::new(target), + Box::new(Expr::String("unrelated".to_string())), + Box::new(Expr::Object(vec![("value".to_string(), Expr::Number(1.0))])), + )) +} + +#[test] +fn unrelated_module_shape_barrier_keeps_guarded_argument_route() { + let mut module = fixture(); + module.init.insert(0, define_property(Expr::Object(vec![]))); + let ir = String::from_utf8(compile_module(&module, opts()).expect("module compiles")) + .expect("LLVM IR is UTF-8"); + let clone_name = "perry_method_argument_shape_clone_ts__Registry__read$pshape_args"; + + assert!( + ir.contains(&format!("call double @{clone_name}(")), + "an unrelated barrier must not suppress the exact guarded route:\n{ir}" + ); + assert!( + !ir.contains("pshape_arg.fallback") + && ir.contains("call double @perry_method_argument_shape_clone_ts__Registry__read("), + "an unrelated barrier must not reintroduce a redundant argument guard:\n{ir}" + ); +} + +#[test] +fn barrier_targeting_argument_stays_on_generic_route() { + let mut module = fixture(); + module.init.insert(2, define_property(Expr::LocalGet(11))); + let ir = String::from_utf8(compile_module(&module, opts()).expect("module compiles")) + .expect("LLVM IR is UTF-8"); + let clone_name = "perry_method_argument_shape_clone_ts__Registry__read$pshape_args"; + + assert!( + !ir.contains(&format!("call double @{clone_name}(")), + "a value reshaped before the call must not receive a containment route:\n{ir}" + ); +} + +#[test] +fn field_read_before_terminal_publication_gets_only_the_guarded_route() { + let mut module = fixture(); + let param_id = module.classes[1].methods[0].params[0].id; + module.classes[1].methods[0] + .body + .push(Stmt::Return(Some(Expr::LocalGet(param_id)))); + + let session = crate::opt_report::test_support::Session::start(); + let ir = String::from_utf8(compile_module(&module, opts()).expect("module compiles")) + .expect("LLVM IR is UTF-8"); + let clone_name = "perry_method_argument_shape_clone_ts__Registry__read$pshape_args"; + assert!( + ir.contains(&format!("call double @{clone_name}(")), + "a direct read performed before publication should use the guarded clone:\n{ir}" + ); + let entries = session.entries(); + assert!( + !entries.iter().any(|entry| { + entry.name == "entity" + && entry.local_id == Some(11) + && entry.outcome == crate::opt_report::Outcome::Selected + }), + "a publishing clone must not preserve the caller's broad Ptr fact: {entries:#?}" + ); +} + +#[test] +fn field_read_after_publication_does_not_get_a_clone() { + let mut module = fixture(); + let method = &mut module.classes[1].methods[0]; + method + .body + .insert(0, Stmt::Expr(Expr::LocalGet(method.params[0].id))); + let ir = String::from_utf8(compile_module(&module, opts()).expect("module compiles")) + .expect("LLVM IR is UTF-8"); + assert!( + !ir.contains("Registry__read$pshape_args"), + "an entry shape proof cannot license a field read after publication:\n{ir}" + ); +} + +#[test] +fn forwarded_clone_parameter_retains_runtime_guard_and_fallback() { + let mut module = fixture(); + let entity_param = 21; + let forward = function( + 201, + "forward", + vec![param( + entity_param, + "entity", + Type::Named("Entity".to_string()), + )], + vec![ + Stmt::Expr(field_get(entity_param, "id")), + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(Expr::This), + property: "read".to_string(), + byte_offset: 0, + }), + args: vec![Expr::LocalGet(entity_param)], + type_args: Vec::new(), + byte_offset: 0, + }), + ], + ); + module.classes[1].methods.push(forward); + let ir = String::from_utf8(compile_module(&module, opts()).expect("module compiles")) + .expect("LLVM IR is UTF-8"); + + assert!( + ir.contains("Registry__forward$pshape_args") + && ir.contains( + "call double @perry_method_argument_shape_clone_ts__Registry__read$pshape_args(" + ), + "the forwarding clone must route its selected parameter onward:\n{ir}" + ); + assert!( + ir.contains("pshape_arg.fallback"), + "a fact inherited from a dynamic clone boundary must retain an exact guard and generic fallback:\n{ir}" + ); +} diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index d3a5b2b772..4212abeaf2 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -1150,6 +1150,8 @@ pub(super) fn compile_closure( local_imported_object_aliases: HashMap::new(), imported_vars: &cross_module.imported_vars, imported_object_literals: &cross_module.imported_object_literals, + short_spread_method_candidates: &cross_module.short_spread_method_candidates, + object_literal_method_candidates: &cross_module.object_literal_method_candidates, compile_time_constants: native_facts.compile_time_constants(), target_triple: &cross_module.target_triple, app_metadata: &cross_module.app_metadata, diff --git a/crates/perry-codegen/src/codegen/emission_order_tests.rs b/crates/perry-codegen/src/codegen/emission_order_tests.rs index 7d45f9b9d1..7ef2c404d5 100644 --- a/crates/perry-codegen/src/codegen/emission_order_tests.rs +++ b/crates/perry-codegen/src/codegen/emission_order_tests.rs @@ -87,6 +87,8 @@ fn ir_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 7c02b81105..943f7f6bb4 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -919,6 +919,8 @@ pub(super) fn compile_module_entry( local_imported_object_aliases: HashMap::new(), imported_vars: &cross_module.imported_vars, imported_object_literals: &cross_module.imported_object_literals, + short_spread_method_candidates: &cross_module.short_spread_method_candidates, + object_literal_method_candidates: &cross_module.object_literal_method_candidates, compile_time_constants: main_native_facts.compile_time_constants(), target_triple: &cross_module.target_triple, app_metadata: &cross_module.app_metadata, @@ -1628,6 +1630,8 @@ pub(super) fn compile_module_entry( local_imported_object_aliases: HashMap::new(), imported_vars: &cross_module.imported_vars, imported_object_literals: &cross_module.imported_object_literals, + short_spread_method_candidates: &cross_module.short_spread_method_candidates, + object_literal_method_candidates: &cross_module.object_literal_method_candidates, compile_time_constants: init_native_facts.compile_time_constants(), target_triple: &cross_module.target_triple, app_metadata: &cross_module.app_metadata, diff --git a/crates/perry-codegen/src/codegen/entry/tests.rs b/crates/perry-codegen/src/codegen/entry/tests.rs index e329f49756..0827ba7b27 100644 --- a/crates/perry-codegen/src/codegen/entry/tests.rs +++ b/crates/perry-codegen/src/codegen/entry/tests.rs @@ -22,6 +22,8 @@ fn entry_opts(output_type: &str) -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 28450c874f..4c01eda022 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -1171,6 +1171,8 @@ pub(super) fn compile_function( local_imported_object_aliases: HashMap::new(), imported_vars: &cross_module.imported_vars, imported_object_literals: &cross_module.imported_object_literals, + short_spread_method_candidates: &cross_module.short_spread_method_candidates, + object_literal_method_candidates: &cross_module.object_literal_method_candidates, compile_time_constants: native_facts.compile_time_constants(), target_triple: &cross_module.target_triple, app_metadata: &cross_module.app_metadata, diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 4968f1eadd..7926d4e5a3 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -968,7 +968,7 @@ pub(super) fn scoped_method_name( /// changing it desyncs cross-module symbol references (a module's prefix is /// `sanitize(module_name)` at the definition site and must match the prefix the /// importing module re-derives). -pub(super) fn sanitize(name: &str) -> String { +pub(crate) fn sanitize(name: &str) -> String { let mut s: String = name .chars() .map(|c| { diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 65a3b7497b..3435682882 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -503,6 +503,8 @@ pub(super) fn compile_method( local_imported_object_aliases: HashMap::new(), imported_vars: &cross_module.imported_vars, imported_object_literals: &cross_module.imported_object_literals, + short_spread_method_candidates: &cross_module.short_spread_method_candidates, + object_literal_method_candidates: &cross_module.object_literal_method_candidates, compile_time_constants: native_facts.compile_time_constants(), target_triple: &cross_module.target_triple, app_metadata: &cross_module.app_metadata, @@ -1794,6 +1796,8 @@ pub(super) fn compile_static_method( local_imported_object_aliases: HashMap::new(), imported_vars: &cross_module.imported_vars, imported_object_literals: &cross_module.imported_object_literals, + short_spread_method_candidates: &cross_module.short_spread_method_candidates, + object_literal_method_candidates: &cross_module.object_literal_method_candidates, compile_time_constants: native_facts.compile_time_constants(), target_triple: &cross_module.target_triple, app_metadata: &cross_module.app_metadata, diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 670522babf..1c7c480776 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -242,6 +242,7 @@ pub(crate) use helpers::{ pub use opts::{ AppMetadata, CompileOptions, ExportedObjectLiteralCapability, FpContractMode, ImportedClass, ImportedObjectLiteral, ImportedObjectLiteralMethod, NamespaceEntry, NamespaceEntryKind, + ObjectLiteralMethodCandidate, ShortSpreadMethodCandidate, }; pub(crate) use opts::{CrossModuleCtx, ImportedCtor}; pub(crate) use param_guard::scalar_descriptor_rep; @@ -266,7 +267,7 @@ use function::{ }; use helpers::{ collect_return_class, emit_buffer_alias_metadata, function_body_returns_generator_object, - sanitize, + sanitize, scoped_method_name, }; // Collector and boxing-analysis walkers live in dedicated modules. The @@ -345,6 +346,54 @@ pub(crate) fn static_method_registry_key(method_name: &str) -> String { format!("__perry_static__{}", method_name) } +/// Harvest concrete method capabilities before modules enter parallel +/// codegen. This deliberately includes non-exported classes: a generic +/// library can receive their instances through a callback or registration API +/// without importing their type (perform-ecs is the motivating case). +pub fn short_spread_method_capabilities(hir: &HirModule) -> Vec { + let source_prefix = sanitize(&hir.name); + let mut used_keys_globals = std::collections::HashSet::new(); + let mut out = Vec::new(); + for class in &hir.classes { + let base = format!( + "perry_class_keys_{}__{}", + source_prefix, + sanitize(&class.name) + ); + let keys_global = if used_keys_globals.insert(base.clone()) { + base + } else { + let mut suffix = 1u32; + loop { + let candidate = format!("{base}_{suffix}"); + if used_keys_globals.insert(candidate.clone()) { + break candidate; + } + suffix += 1; + } + }; + let shape_id_global = + crate::typed_shape::shape_id_global_name_from_keys_global(&keys_global); + for method in &class.methods { + // Rest-shaped methods need a different direct ABI. This includes + // the compiler's hidden `arguments` parameter, which is also + // marked as rest. Both stay on the generic apply path. + if method.params.iter().any(|param| param.is_rest) { + continue; + } + out.push(ShortSpreadMethodCandidate { + class_id: class.id, + method_name: method.name.clone(), + source_prefix: source_prefix.clone(), + target: scoped_method_name(&source_prefix, &class.name, &method.name), + shape_id_global: shape_id_global.clone(), + declared_count: method.params.len(), + }); + } + } + out +} + /// Compile a Perry HIR module to an object file via LLVM IR. /// /// CRITICAL (#686): `hir` MUST be `&HirModule` (shared reference), never @@ -1110,7 +1159,11 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> &mut used_class_keys_globals, ); llmod.add_internal_global(&global_name, I64, "0"); - llmod.add_internal_global( + // #8772: the immutable class ShapeId is a producer-authored + // whole-program capability. Generic callers in other modules load it + // to guard reverse-discovered direct method arms. The keys array stays + // private; only the opaque process-unique identity is exported. + llmod.add_global( &crate::typed_shape::shape_id_global_name_from_keys_global(&global_name), I32, "0", @@ -2031,11 +2084,14 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // class; every routed call emits an exact runtime class+shape guard. Keep // this disjoint from typed/index/undefined clone families, whose // trampolines have separate routing conventions. - let local_class_names: std::collections::HashSet<&str> = hir - .classes - .iter() - .map(|class| class.name.as_str()) - .collect(); + // Argument layouts may come from an imported class stub. The clone itself + // is still emitted only for a method body owned by this module; imported + // field metadata, class id and class-keys/ShapeId globals provide the same + // exact runtime guard and offsets as local metadata. This is required by + // perform-ecs: `ECS.addComponentsToEntity` is local to ECS.ts while its + // `Entity` parameter is declared in Entity.ts. + let visible_class_names: std::collections::HashSet<&str> = + receiver_class_table.keys().map(String::as_str).collect(); let mut pshape_arg_methods = std::collections::HashMap::new(); for class in &hir.classes { for method in &class.methods { @@ -2050,19 +2106,13 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> { continue; } - let Some(mut plan) = crate::collectors::method_proven_shape_args( + let Some(plan) = crate::collectors::method_proven_shape_args( method, receiver_class_table, - &local_class_names, - &module_dispatch_facts, + &visible_class_names, ) else { continue; }; - // Imported argument classes need producer-authored shape metadata - // and clone publication. Until that capability is explicit, keep - // imports/re-exports on the generic path. - plan.args - .retain(|arg| local_class_names.contains(arg.fact.class_name.as_str())); if !plan.args.is_empty() { pshape_arg_methods.insert(key, plan); } @@ -2074,7 +2124,13 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> key.clone(), plan.args .iter() - .map(|arg| (arg.param_index, arg.fact.class_name.clone())) + .map(|arg| { + ( + arg.param_index, + arg.fact.class_name.clone(), + arg.preserves_containment, + ) + }) .collect(), ) }, @@ -2230,24 +2286,60 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> .map(|object| (object.local_binding.clone(), object.clone())) }) .collect(); - let imported_object_producers: std::collections::BTreeSet<(String, u32)> = + let mut imported_object_producers: std::collections::BTreeSet<(String, u32)> = imported_object_literals .values() .map(|object| (object.source_prefix.clone(), object.source_global_id)) .collect(); - for (source_prefix, source_global_id) in imported_object_producers { + for (source_prefix, source_global_id) in &imported_object_producers { llmod.add_external_global( &format!("perry_global_{source_prefix}__{source_global_id}"), DOUBLE, ); } + for candidate in opts.object_literal_method_candidates.values().flatten() { + if candidate.source_prefix != module_prefix + && imported_object_producers + .insert((candidate.source_prefix.clone(), candidate.source_global_id)) + { + llmod.add_external_global( + &format!( + "perry_global_{}__{}", + candidate.source_prefix, candidate.source_global_id + ), + DOUBLE, + ); + } + } + + // #8772: declare the opaque ShapeId slots published by concrete classes + // in other modules. Local candidates already have a defining global in + // this module and must not be redeclared as external. + let mut declared_short_spread_shapes = std::collections::HashSet::new(); + for candidate in opts.short_spread_method_candidates.values().flatten() { + if candidate.source_prefix != module_prefix + && declared_short_spread_shapes.insert(candidate.shape_id_global.clone()) + { + llmod.add_external_global(&candidate.shape_id_global, I32); + } + } + for candidate in opts.object_literal_method_candidates.values().flatten() { + if candidate.source_prefix != module_prefix + && declared_short_spread_shapes.insert(candidate.shape_id_global.clone()) + { + llmod.add_external_global(&candidate.shape_id_global, I32); + } + } + let mut cross_module = CrossModuleCtx { namespace_imports: opts.namespace_imports.iter().cloned().collect(), namespace_member_nested: opts.namespace_member_nested.iter().cloned().collect(), namespace_member_prefixes: opts.namespace_member_prefixes, namespace_member_origin_names: opts.namespace_member_origin_names, imported_async_funcs: opts.imported_async_funcs, + short_spread_method_candidates: Arc::clone(&opts.short_spread_method_candidates), + object_literal_method_candidates: Arc::clone(&opts.object_literal_method_candidates), local_async_funcs, local_generator_funcs, async_step_closures: hir.async_step_closures.iter().copied().collect(), diff --git a/crates/perry-codegen/src/codegen/number_exactness_tests.rs b/crates/perry-codegen/src/codegen/number_exactness_tests.rs index 74ad0c16b6..404cefd592 100644 --- a/crates/perry-codegen/src/codegen/number_exactness_tests.rs +++ b/crates/perry-codegen/src/codegen/number_exactness_tests.rs @@ -46,6 +46,8 @@ fn ir_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index cc33764172..194ea2c3c9 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -231,6 +231,21 @@ pub struct CompileOptions { /// carries the class HIR, the module prefix of its origin, and an /// optional local alias. pub imported_classes: Vec, + /// Whole-program concrete method implementations eligible for the short + /// trailing-spread direct-call lowering (#8772). Unlike `imported_classes`, + /// this capability also flows from a consumer module back to a generic + /// library module: perform-ecs owns the hot `component.reset(...args)` + /// call, while benchmark adapters define the concrete Position/Velocity + /// classes. The producer publishes the exact class id, immutable ShapeId + /// global and public method ABI; the caller still guards every arm and + /// retains the generic apply fallback. + pub short_spread_method_candidates: + std::sync::Arc>>, + /// Whole-program stable exported object-literal methods eligible for an + /// exact guarded call from an otherwise dynamic receiver (#8775). This is + /// reverse-flow metadata: the calling module need not import the producer. + pub object_literal_method_candidates: + std::sync::Arc>>, /// Imported enum member lists, keyed by the local name under which /// the enum is visible in this module. pub imported_enums: Vec<(String, Vec<(String, perry_hir::EnumValue)>)>, @@ -615,15 +630,44 @@ pub struct ImportedClass { pub object_literal: Option, } +/// Producer-authored capability for one concrete class method that can be +/// called by the guarded short trailing-spread lowering (#8772). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShortSpreadMethodCandidate { + pub class_id: u32, + pub method_name: String, + pub source_prefix: String, + pub target: String, + pub shape_id_global: String, + pub declared_count: usize, +} + /// One concise own method published by an exported object-literal capability. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ImportedObjectLiteralMethod { pub name: String, pub func_id: u32, + /// Exact closure-call ABI function stored in the live own slot. This is a + /// `perry_closure_*` body for a `this`-capturing method and a + /// `__perry_wrap_perry_fn_*` wrapper for HIR's lifted no-`this` method. + pub target: String, pub param_count: usize, pub field_index: u32, } +/// One producer-authored exported object-literal method that may be selected +/// from a dynamic receiver after exact identity, class, ShapeId, own-slot, and +/// closure-function guards. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ObjectLiteralMethodCandidate { + pub class_id: u32, + pub source_prefix: String, + pub source_export_name: String, + pub source_global_id: u32, + pub shape_id_global: String, + pub method: ImportedObjectLiteralMethod, +} + /// Consumer-resolved capability for one imported object-literal binding. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ImportedObjectLiteral { @@ -647,6 +691,7 @@ pub struct ExportedObjectLiteralCapability { pub class_name: String, pub class_id: u32, pub global_id: u32, + pub shape_id_global: String, pub field_names: Vec, pub methods: Vec, } @@ -693,6 +738,10 @@ pub(crate) struct CrossModuleCtx { /// `CompileOptions::namespace_member_origin_names`. pub namespace_member_origin_names: std::collections::HashMap<(String, String), String>, pub imported_async_funcs: std::collections::HashSet, + pub short_spread_method_candidates: + std::sync::Arc>>, + pub object_literal_method_candidates: + std::sync::Arc>>, /// FuncIds of locally-defined async functions in this module. Populated /// from `hir.functions.is_async`. Used by `is_promise_expr` to refine /// `let p = asyncFn();` to `Promise(_)` so subsequent `p.then(cb)` @@ -980,8 +1029,9 @@ pub(crate) struct CrossModuleCtx { std::collections::HashMap<(String, String), crate::collectors::PtrShapeLocal>, /// Module-local method bodies with a guarded exact-shape parameter clone. /// The plan names every formal that must pass its class+shape guard before - /// the tagged-ABI `$pshape_args` body may be entered. These capabilities - /// are intentionally not exported in the first increment. + /// the tagged-ABI `$pshape_args` body may be entered. Parameter layouts may + /// come from imported class stubs, but clone symbols remain private to the + /// method's defining module and are never imported as capabilities. pub pshape_arg_methods: std::collections::HashMap<(String, String), crate::collectors::ProvenShapeArgPlan>, /// #7142: the subset of [`Self::pshape_methods`] whose clone the class-id diff --git a/crates/perry-codegen/src/collectors/hir_facts.rs b/crates/perry-codegen/src/collectors/hir_facts.rs index df4282749f..8f5688aeb6 100644 --- a/crates/perry-codegen/src/collectors/hir_facts.rs +++ b/crates/perry-codegen/src/collectors/hir_facts.rs @@ -182,6 +182,10 @@ pub(crate) struct ShapeStabilityFacts { /// and unguarded direct method dispatch /// (`lower_call/property_get/dynamic_dispatch.rs`). pub shape_proven_ptr_locals: HashMap, + /// Fresh-object containment facts consumed exclusively by guarded + /// `$pshape_args` routes. This map remains available in modules with + /// shape barriers, but it must never license guard-free field access. + pub guarded_argument_route_locals: HashMap, /// Representation-selection Phase 4a.3: function-locals proven to satisfy /// the `Ptr` invariants (raw-f64-or-hole slots, never-shrinking /// length, no stale-binding path) for their entire lifetime @@ -406,6 +410,15 @@ impl TypeFacts { self.shape_stability.shape_proven_ptr_locals.get(&local_id) } + pub(crate) fn guarded_argument_route_local( + &self, + local_id: u32, + ) -> Option<&super::PtrShapeLocal> { + self.shape_stability + .guarded_argument_route_locals + .get(&local_id) + } + /// Representation-selection Phase 4a.3: the numeric-array proof for a /// local, when it is a proven `Ptr` local /// (`collectors/ptr_numarray.rs`). @@ -662,6 +675,20 @@ pub(crate) fn collect_type_facts( spec_numeric_params, ); array_facts.exact_numeric_element_fields = exact_numeric_element_fields; + let guarded_argument_route_locals = if module_dispatch.has_argument_shape_routes() { + super::ptr_shape::collect_guarded_argument_route_locals( + stmts, + boxed_vars, + module_globals, + classes, + module_dispatch, + ¬_bigint_locals, + &element_shape_facts, + spec_numeric_params, + ) + } else { + HashMap::new() + }; // Representation-selection Phase 4a.3: `Ptr` locals. Gated on // `PERRY_PTR_NUMARRAY_LOCALS`, the module-wide §5.2 barrier scan, and the // array-specific prototype-indexed-write kill inside the collector. @@ -719,6 +746,7 @@ pub(crate) fn collect_type_facts( shape_stability: ShapeStabilityFacts { scalar_replaceable_object_locals, shape_proven_ptr_locals, + guarded_argument_route_locals, num_array_locals, }, materialization_hazards, diff --git a/crates/perry-codegen/src/collectors/object_literal_exports.rs b/crates/perry-codegen/src/collectors/object_literal_exports.rs index 20f551e549..7eceb2673c 100644 --- a/crates/perry-codegen/src/collectors/object_literal_exports.rs +++ b/crates/perry-codegen/src/collectors/object_literal_exports.rs @@ -16,6 +16,95 @@ fn local_get_is(expr: &Expr, expected: u32) -> bool { matches!(expr, Expr::LocalGet(id) if *id == expected) } +fn eligible_method( + hir: &Module, + class: &perry_hir::Class, + key: &str, + value: &Expr, +) -> Option { + let source_prefix = crate::codegen::helpers::sanitize(&hir.name); + let (func_id, params, target) = match value { + Expr::Closure { + func_id, + params, + is_arrow: false, + is_async: false, + is_generator: false, + .. + } => ( + *func_id, + params, + format!("perry_closure_{source_prefix}__{func_id}"), + ), + // HIR lifts a concise method that does not read `this` into a normal + // private function and stores its closure wrapper through IndexSet. + // The live-slot identity guard must compare/call that wrapper, not a + // nonexistent `perry_closure_*` symbol. + Expr::FuncRef(func_id) => { + let function = hir + .functions + .iter() + .find(|function| function.id == *func_id)?; + if function.is_async || function.is_generator { + return None; + } + let target = format!( + "__perry_wrap_perry_fn_{}__{}", + source_prefix, + crate::codegen::helpers::sanitize(&function.name) + ); + (*func_id, &function.params, target) + } + _ => return None, + }; + // The first slice uses the existing exact-arity closure guard. Rest and + // synthesized `arguments` slots remain generic. + if params + .iter() + .any(|param| param.is_rest || param.arguments_object.is_some()) + { + return None; + } + let field_index = class.fields.iter().position(|field| field.name == key)? as u32; + Some(ImportedObjectLiteralMethod { + name: key.to_string(), + func_id, + target, + param_count: params.len(), + field_index, + }) +} + +fn class_shape_id_global(hir: &Module, class_name: &str) -> Option { + let source_prefix = crate::codegen::helpers::sanitize(&hir.name); + let mut used = HashSet::new(); + for class in &hir.classes { + let base = format!( + "perry_class_keys_{}__{}", + source_prefix, + crate::codegen::helpers::sanitize(&class.name) + ); + let keys_global = if used.insert(base.clone()) { + base + } else { + let mut suffix = 1u32; + loop { + let candidate = format!("{base}_{suffix}"); + if used.insert(candidate.clone()) { + break candidate; + } + suffix += 1; + } + }; + if class.name == class_name { + return Some(crate::typed_shape::shape_id_global_name_from_keys_global( + &keys_global, + )); + } + } + None +} + fn capability_from_init( hir: &Module, global_id: u32, @@ -59,17 +148,26 @@ fn capability_from_init( return None; } - // Last source write wins. A later data/function-valued write to the same - // key deliberately erases an earlier concise-method capability. + let shape_id_global = class_shape_id_global(hir, &class.name)?; + + // Last source write wins. A non-arrow closure stored through the ordinary + // data-property path is eligible too: HIR uses that path for a concise + // method whose body does not read `this` (the public suite's `perform(ctx)` + // shape), as well as for stable function-valued properties. The exact live + // closure guard makes both cases safe. Other values erase the capability. let mut final_methods: HashMap> = HashMap::new(); let mut saw_return = false; for stmt in body { match stmt { - Stmt::Expr(Expr::IndexSet { object, index, .. }) if local_get_is(object, param.id) => { + Stmt::Expr(Expr::IndexSet { + object, + index, + value, + }) if local_get_is(object, param.id) => { let Expr::String(key) = index.as_ref() else { return None; }; - final_methods.insert(key.clone(), None); + final_methods.insert(key.clone(), eligible_method(hir, class, key, value)); } Stmt::Expr(Expr::Call { callee, args, .. }) => { let Expr::ExternFuncRef { name, .. } = callee.as_ref() else { @@ -84,38 +182,7 @@ fn capability_from_init( if !local_get_is(receiver, param.id) { return None; } - let Expr::Closure { - func_id, - params, - captures_this: true, - is_arrow: false, - is_async: false, - is_generator: false, - .. - } = value - else { - final_methods.insert(key.clone(), None); - continue; - }; - // The first slice uses the existing exact-arity closure guard. - // Rest and synthesized `arguments` slots remain generic. - if params - .iter() - .any(|param| param.is_rest || param.arguments_object.is_some()) - { - final_methods.insert(key.clone(), None); - continue; - } - let field_index = class.fields.iter().position(|field| field.name == *key)? as u32; - final_methods.insert( - key.clone(), - Some(ImportedObjectLiteralMethod { - name: key.clone(), - func_id: *func_id, - param_count: params.len(), - field_index, - }), - ); + final_methods.insert(key.clone(), eligible_method(hir, class, key, value)); } Stmt::Return(Some(value)) if local_get_is(value, param.id) && !saw_return => { saw_return = true; @@ -150,6 +217,7 @@ fn capability_from_init( class_name: class.name.clone(), class_id: class.id, global_id, + shape_id_global, field_names, methods, }) diff --git a/crates/perry-codegen/src/collectors/proven_args.rs b/crates/perry-codegen/src/collectors/proven_args.rs index c89a50e52c..6a6e589528 100644 --- a/crates/perry-codegen/src/collectors/proven_args.rs +++ b/crates/perry-codegen/src/collectors/proven_args.rs @@ -9,10 +9,11 @@ //! tagged ABI, so the parameter is stored in (and reloaded from) its ordinary //! shadow-bound slot at every fixed-offset field access. //! -//! This first increment deliberately accepts read-only declared-field uses. -//! Stores through an aliased parameter have additional frozen/sealed-object -//! semantics, and a bare use can publish the object to code the proof cannot -//! inspect. Both therefore keep the generic body. +//! Direct declared-field reads are accepted only while the parameter remains +//! contained. A later bare use may publish it, but then no later field read may +//! consume the entry proof and the caller must drop its post-call containment +//! fact. Stores/reassignment still keep the generic body because their +//! frozen/sealed and changing-binding semantics need stronger modeling. use std::collections::{HashMap, HashSet}; @@ -30,6 +31,10 @@ pub struct ProvenShapeArg { pub param_index: usize, pub param_id: u32, pub fact: PtrShapeLocal, + /// True only when the method never publishes this argument. A false value + /// still permits guarded direct reads before the first escape, but the + /// caller must drop its broad post-call containment fact. + pub preserves_containment: bool, } /// The single, non-combinatorial exact-shape argument clone for a method. @@ -44,21 +49,20 @@ pub(crate) fn pshape_args_method_name(public_name: &str) -> String { format!("{public_name}$pshape_args") } -/// Nominate read-only parameters whose body uses declared class fields. +/// Nominate parameters whose contained prefix reads declared class fields. /// /// A declared `C` type or unique unannotated field signature only chooses the /// expected class for the runtime guard emitted at every routed call site. /// Classes absent from `classes`, optional/default/rest/`arguments` -/// parameters, async bodies, and any module carrying the conservative -/// shape-barrier latch stand down. +/// parameters, and async bodies stand down. Module-wide shape barriers do not +/// reject a clone: every route revalidates the live argument's exact class and +/// shape, and only a separate containment proof can make that route reachable. pub(crate) fn method_proven_shape_args( method: &Function, classes: &HashMap, - local_class_names: &HashSet<&str>, - module_dispatch: &ModuleDispatchFacts, + visible_class_names: &HashSet<&str>, ) -> Option { if !super::ptr_shape::ptr_shape_locals_enabled() - || module_dispatch.has_shape_barrier_sites() || method.is_async || method.is_generator || method.was_plain_async @@ -72,10 +76,11 @@ pub(crate) fn method_proven_shape_args( if param.default.is_some() || param.is_rest || param.arguments_object.is_some() { continue; } - let mut use_check = ReadOnlyParamUse { + let mut use_check = PrefixContainedParamUse { param_id: param.id, field_reads: HashSet::new(), safe: true, + escaped: false, }; use_check.walk_stmts(&method.body); if !use_check.safe || use_check.field_reads.is_empty() { @@ -83,7 +88,7 @@ pub(crate) fn method_proven_shape_args( } let class_name = match ¶m.ty { Type::Named(class_name) => { - if !local_class_names.contains(class_name.as_str()) + if !visible_class_names.contains(class_name.as_str()) || !class_fields_cover(classes, class_name, &use_check.field_reads) { continue; @@ -94,7 +99,7 @@ pub(crate) fn method_proven_shape_args( // is only a nomination mechanism: runtime guards still prove the // exact class and shape at every route. Type::Any => { - let mut candidates = local_class_names.iter().filter(|class_name| { + let mut candidates = visible_class_names.iter().filter(|class_name| { class_fields_cover(classes, class_name, &use_check.field_reads) }); let Some(candidate) = candidates.next() else { @@ -120,6 +125,7 @@ pub(crate) fn method_proven_shape_args( numeric_fields: HashSet::new(), report_name: crate::opt_report::enabled().then(|| param.name.clone()), }, + preserves_containment: !use_check.escaped, }); } @@ -138,18 +144,20 @@ fn class_fields_cover( !fields.is_empty() && field_reads.is_subset(&fields) } -/// The guarded clone's audited body cannot retain or reshape a matching -/// tracked argument, so that exact route preserves caller-side containment. +/// Check the caller-side provenance and alias terms for one guarded route. +/// `require_post_call_containment` additionally rejects clones that publish the +/// parameter after their specialized prefix. pub(super) fn route_preserves_argument_containment( module_dispatch: &ModuleDispatchFacts, candidates: &HashMap, roots: &HashMap, - receiver_root: u32, - owner_class: &str, + receiver_root: Option, + owner_class: Option<&str>, method: &str, param_index: usize, arg: &Expr, call_args: &[Expr], + require_post_call_containment: bool, ) -> bool { let Expr::LocalGet(id) = arg else { return false; @@ -161,7 +169,7 @@ pub(super) fn route_preserves_argument_containment( // formal can reshape a selected argument between its entry guard and a // fixed-offset read. Preserve containment only when this tracked object is // unique across every value supplied to the call. - if *root == receiver_root + if receiver_root == Some(*root) || call_args.iter().enumerate().any(|(other_index, other)| { other_index != param_index && matches!( @@ -172,20 +180,28 @@ pub(super) fn route_preserves_argument_containment( { return false; } - let Some(expected) = module_dispatch.argument_shape_class(owner_class, method, param_index) - else { + let route = match owner_class { + Some(owner) => module_dispatch.argument_shape_route(owner, method, param_index), + None => module_dispatch.unique_argument_shape_class(method, param_index), + }; + let Some((expected, preserves_containment)) = route else { return false; }; - candidates.get(root).is_some_and(|got| got == expected) + (!require_post_call_containment || preserves_containment) + && candidates.get(root).is_some_and(|got| got == expected) } -struct ReadOnlyParamUse { +/// Direct declared-field reads are safe until the first bare use publishes the +/// parameter. The tagged clone may continue generically after publication, +/// but no later field access may consume its entry shape proof. +struct PrefixContainedParamUse { param_id: u32, field_reads: HashSet, safe: bool, + escaped: bool, } -impl ReadOnlyParamUse { +impl PrefixContainedParamUse { fn walk_stmts(&mut self, stmts: &[Stmt]) { for stmt in stmts { self.walk_stmt(stmt); @@ -212,9 +228,19 @@ impl ReadOnlyParamUse { self.walk_stmts(branch); } } - Stmt::While { condition, body } | Stmt::DoWhile { condition, body } => { + Stmt::While { condition, body } => { + let field_count = self.field_reads.len(); + let escaped = self.escaped; self.walk_expr(condition); self.walk_stmts(body); + self.finish_repeated_region(field_count, escaped); + } + Stmt::DoWhile { condition, body } => { + let field_count = self.field_reads.len(); + let escaped = self.escaped; + self.walk_stmts(body); + self.walk_expr(condition); + self.finish_repeated_region(field_count, escaped); } Stmt::For { init, @@ -225,6 +251,8 @@ impl ReadOnlyParamUse { if let Some(init) = init { self.walk_stmt(init); } + let field_count = self.field_reads.len(); + let escaped = self.escaped; if let Some(condition) = condition { self.walk_expr(condition); } @@ -232,6 +260,7 @@ impl ReadOnlyParamUse { self.walk_expr(update); } self.walk_stmts(body); + self.finish_repeated_region(field_count, escaped); } Stmt::Try { body, @@ -271,6 +300,15 @@ impl ReadOnlyParamUse { } } + fn finish_repeated_region(&mut self, field_count: usize, escaped: bool) { + // Even when every first-iteration read precedes publication, the next + // iteration would perform that read after publication. Refuse the + // whole-parameter overlay when a repeated region contains both. + if !escaped && self.escaped && self.field_reads.len() > field_count { + self.safe = false; + } + } + fn walk_expr(&mut self, expr: &Expr) { if !self.safe { return; @@ -282,7 +320,11 @@ impl ReadOnlyParamUse { Expr::PropertyGet { object, property, .. } if matches!(object.as_ref(), Expr::LocalGet(id) if *id == self.param_id) => { - self.field_reads.insert(property.clone()); + if self.escaped { + self.safe = false; + } else { + self.field_reads.insert(property.clone()); + } } // A direct store/update has frozen/sealed and setter semantics not // implied by an exact-shape entry guard. @@ -290,7 +332,9 @@ impl ReadOnlyParamUse { { self.safe = false; } - Expr::LocalGet(id) if *id == self.param_id => self.safe = false, + // A bare use publishes the object. Reads already performed remain + // valid, but the entry proof cannot license any later field read. + Expr::LocalGet(id) if *id == self.param_id => self.escaped = true, Expr::LocalSet(id, _) if *id == self.param_id => self.safe = false, Expr::Closure { body, .. } => { perry_hir::walker::walk_expr_children(expr, &mut |child| self.walk_expr(child)); diff --git a/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs b/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs index efee6f2212..7ef6ddb43e 100644 --- a/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs +++ b/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs @@ -57,6 +57,8 @@ fn ir_opts(is_entry: bool) -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/collectors/ptr_shape.rs b/crates/perry-codegen/src/collectors/ptr_shape.rs index dc482bc1f5..0bed0153ce 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape.rs @@ -325,6 +325,77 @@ pub(crate) fn collect_shape_proven_ptr_locals_and_element_fields( not_bigint_locals: &HashSet, element_facts: &ElementShapeFacts, numeric_param_seeds: &HashSet, +) -> (HashMap, HashMap>) { + collect_shape_proven_ptr_locals_impl( + stmts, + boxed_vars, + module_globals, + classes, + module_dispatch, + not_bigint_locals, + element_facts, + numeric_param_seeds, + CollectionPurpose::UnguardedRepresentation, + ) +} + +/// Containment facts that may be consumed only by a `$pshape_args` call-site +/// guard. Unlike a guard-free `Ptr` representation, an unrelated +/// module barrier cannot invalidate this fact: the fresh object has not +/// escaped, and the route rechecks its live class and ShapeId immediately +/// before entering the clone. These facts must never feed ordinary field or +/// method lowering. +pub(crate) fn collect_guarded_argument_route_locals( + stmts: &[Stmt], + boxed_vars: &HashSet, + module_globals: &HashMap, + classes: &HashMap, + module_dispatch: &ModuleDispatchFacts, + not_bigint_locals: &HashSet, + element_facts: &ElementShapeFacts, + numeric_param_seeds: &HashSet, +) -> HashMap { + // This second pass is a proof query, not a guard-free representation + // selection. Suppress Ptr report rows so it cannot claim that a + // barrier-gated local received the broader optimization. + let _quiet = report::SuppressScope::new(); + let (mut facts, _) = collect_shape_proven_ptr_locals_impl( + stmts, + boxed_vars, + module_globals, + classes, + module_dispatch, + not_bigint_locals, + element_facts, + numeric_param_seeds, + CollectionPurpose::GuardedArgumentRoute, + ); + // The guarded route consumes class/containment only. Do not carry a raw + // numeric-field representation claim into this deliberately narrower map. + for fact in facts.values_mut() { + fact.numeric_fields.clear(); + fact.report_name = None; + } + facts +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum CollectionPurpose { + UnguardedRepresentation, + GuardedArgumentRoute, +} + +#[allow(clippy::too_many_arguments)] +fn collect_shape_proven_ptr_locals_impl( + stmts: &[Stmt], + boxed_vars: &HashSet, + module_globals: &HashMap, + classes: &HashMap, + module_dispatch: &ModuleDispatchFacts, + not_bigint_locals: &HashSet, + element_facts: &ElementShapeFacts, + numeric_param_seeds: &HashSet, + purpose: CollectionPurpose, ) -> (HashMap, HashMap>) { // #7152: Perry's own `cjs_wrap` preamble, recognised once for this region. // One scan of the top-level statement list on anything else, then a @@ -332,7 +403,9 @@ pub(crate) fn collect_shape_proven_ptr_locals_and_element_fields( let preamble = super::cjs_scaffolding::preamble_in_region(stmts); let bail = if !ptr_shape_locals_enabled() { Some(report::GATE_DISABLED) - } else if module_dispatch.has_shape_barrier_sites() { + } else if purpose == CollectionPurpose::UnguardedRepresentation + && module_dispatch.has_shape_barrier_sites() + { Some(report::MODULE_BARRIER) } else { None @@ -459,6 +532,7 @@ pub(crate) fn collect_shape_proven_ptr_locals_and_element_fields( element_seeded: &element_seeded, element_facts, in_closure: false, + purpose, }; walk.walk_stmts(stmts); let UseWalk { @@ -881,6 +955,9 @@ struct UseWalk<'a> { /// doc, rule 2) does NOT apply — only the enclosing function's own /// returns are terminators for this local's lifetime. in_closure: bool, + /// Whether this walk is proving the broad guard-free representation or + /// only a fresh-object route protected by an exact argument guard. + purpose: CollectionPurpose, } impl<'a> UseWalk<'a> { @@ -1226,12 +1303,13 @@ impl<'a> UseWalk<'a> { self.module_dispatch, self.candidates, self.roots, - root, - class_name, + Some(root), + Some(class_name), property, param_index, a, args, + self.purpose == CollectionPurpose::UnguardedRepresentation, ) { continue; @@ -1241,6 +1319,37 @@ impl<'a> UseWalk<'a> { return; } } + + // In the route-only proof, method lowering may establish + // the receiver class after this analysis (notably for + // `this.m(fresh)`). Preserve the fresh argument only when + // all emitted clones with this method name and position + // agree on its class. The fact is invisible to ordinary + // field/method lowering and is consumed only beside the + // live class+ShapeId guard. + if self.purpose == CollectionPurpose::GuardedArgumentRoute + && matches!(object.as_ref(), Expr::This | Expr::LocalGet(_)) + { + self.walk_expr(object); + for (param_index, arg) in args.iter().enumerate() { + if super::proven_args::route_preserves_argument_containment( + self.module_dispatch, + self.candidates, + self.roots, + None, + None, + property, + param_index, + arg, + args, + false, + ) { + continue; + } + self.with_ctx(report::ESC_CALL_ARGUMENT, |walk| walk.walk_expr(arg)); + } + return; + } } self.walk_expr(callee); for a in args { diff --git a/crates/perry-codegen/src/collectors/ptr_shape_report.rs b/crates/perry-codegen/src/collectors/ptr_shape_report.rs index 28232bb3ea..b31f55e600 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_report.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_report.rs @@ -219,11 +219,13 @@ pub(super) const ESC_CLOSURE_CAPTURE: ShapeDenial = ShapeDenial { pub(super) const ESC_CALL_ARGUMENT: ShapeDenial = ShapeDenial { rule: RULE2, - reason: "passed as a call argument. There is no mechanism yet by which a \ - shape fact at a call site becomes a fact about the callee's \ - parameter, so any argument position disqualifies.", + reason: "passed to a call that does not preserve whole-lifetime \ + containment. A guarded `$pshape_args` route may still specialize \ + field reads performed before the callee publishes the argument, \ + but the caller cannot retain an unguarded `Ptr` fact after \ + that call.", tier: Tier::CompilerLimitation, - issue: Some("#7034 §1 (argument positions via clone-and-route)"), + issue: Some("#8774 (guarded argument clone-and-route)"), }; pub(super) const ESC_RETURN: ShapeDenial = ShapeDenial { diff --git a/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs b/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs index 6027f7839e..7a902109c6 100644 --- a/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs +++ b/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs @@ -103,7 +103,7 @@ pub struct ModuleDispatchFacts { /// known. The containment walk consults this table only for a statically /// resolved method call whose tracked argument class exactly matches the /// clone's guarded parameter. - argument_shape_routes: HashMap<(String, String, usize), String>, + argument_shape_routes: HashMap<(String, String, usize), ArgumentShapeRoute>, /// Representation-selection Phase 3b, #7170 R1: `LocalId` -> `FuncId` for /// every local that provably names one closure literal, module-wide. /// @@ -120,6 +120,12 @@ pub struct ModuleDispatchFacts { closure_bindings: HashMap, } +#[derive(Debug, Clone)] +struct ArgumentShapeRoute { + class_name: String, + preserves_containment: bool, +} + impl Default for ModuleDispatchFacts { /// Fail safe: a fact set that was never populated must not license the /// scalar-method summary (nor any `Ptr` promotion). @@ -246,31 +252,67 @@ impl ModuleDispatchFacts { /// artifact emission. pub(crate) fn install_argument_shape_routes( &mut self, - routes: impl IntoIterator)>, + routes: impl IntoIterator)>, ) { self.argument_shape_routes.clear(); for ((owner, method), args) in routes { - for (index, class_name) in args { - self.argument_shape_routes - .insert((owner.clone(), method.clone(), index), class_name); + for (index, class_name, preserves_containment) in args { + self.argument_shape_routes.insert( + (owner.clone(), method.clone(), index), + ArgumentShapeRoute { + class_name, + preserves_containment, + }, + ); } } } - /// Expected exact argument class for one emitted `$pshape_args` route. - pub(crate) fn argument_shape_class( + /// Expected exact argument class and post-call containment contract for one + /// emitted `$pshape_args` route. + pub(crate) fn argument_shape_route( &self, owner_class: &str, method_name: &str, param_index: usize, - ) -> Option<&str> { + ) -> Option<(&str, bool)> { self.argument_shape_routes .get(&( owner_class.to_string(), method_name.to_string(), param_index, )) - .map(String::as_str) + .map(|route| (route.class_name.as_str(), route.preserves_containment)) + } + + /// Expected argument class when every emitted clone for this method name + /// and position agrees. This is used only by the guarded-route + /// containment query for a receiver such as `this`, whose concrete class + /// is selected later by method lowering. A missing or conflicting route + /// stands down. + pub(crate) fn unique_argument_shape_class( + &self, + method_name: &str, + param_index: usize, + ) -> Option<(&str, bool)> { + let mut matches = self + .argument_shape_routes + .iter() + .filter(|((_, method, index), _)| method == method_name && *index == param_index) + .map(|(_, route)| (route.class_name.as_str(), route.preserves_containment)); + let first = matches.next()?; + let mut all_preserve = first.1; + for route in matches { + if route.0 != first.0 { + return None; + } + all_preserve &= route.1; + } + Some((first.0, all_preserve)) + } + + pub(crate) fn has_argument_shape_routes(&self) -> bool { + !self.argument_shape_routes.is_empty() } /// Representation-selection Phase 3b, #7170 R1: the `FuncId` that 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 c01dd50d48..a20faba1ee 100644 --- a/crates/perry-codegen/src/expr/array_push_guard_tests.rs +++ b/crates/perry-codegen/src/expr/array_push_guard_tests.rs @@ -58,6 +58,8 @@ fn ir_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/expr/call_spread_short.rs b/crates/perry-codegen/src/expr/call_spread_short.rs index 6e04e54a5b..9b0e3bf50a 100644 --- a/crates/perry-codegen/src/expr/call_spread_short.rs +++ b/crates/perry-codegen/src/expr/call_spread_short.rs @@ -12,6 +12,7 @@ use perry_hir::{CallArg, Expr}; use crate::nanbox::double_literal; use crate::native_value::LoweredValue; +use crate::rooting::Repr; use crate::types::{DOUBLE, I1, I32, I64, PTR}; use super::FnCtx; @@ -22,9 +23,22 @@ const MAX_METHOD_ARMS: usize = 8; #[derive(Clone)] struct DirectCandidate { class_id: u32, - class_name: String, target: String, declared_count: usize, + shape: CandidateShape, + needs_declare: bool, +} + +#[derive(Clone)] +enum CandidateShape { + Local { + class_name: String, + keys_global: String, + }, + Foreign { + cache_key: String, + shape_id_global: String, + }, } /// Collect concrete class implementations in deterministic class-id order. @@ -39,9 +53,9 @@ fn direct_candidates(ctx: &FnCtx<'_>, property: &str) -> Vec { roots.sort_unstable_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(b.0))); let mut out = Vec::new(); - let mut seen = std::collections::HashSet::new(); + let mut seen_class_ids = std::collections::HashSet::new(); for (class_name, class_id) in roots { - let Some(_keys_global) = ctx.class_keys_globals.get(class_name) else { + let Some(keys_global) = ctx.class_keys_globals.get(class_name) else { continue; }; let mut current = Some(class_name.clone()); @@ -51,7 +65,7 @@ fn direct_candidates(ctx: &FnCtx<'_>, property: &str) -> Vec { let unsupported_abi = public_target.starts_with("perry_static_") || matches!(ctx.method_has_rest.get(&key), Some(true)) || matches!(ctx.method_has_synthetic_arguments.get(&key), Some(true)); - if !unsupported_abi && seen.insert((class_id, public_target.clone())) { + if !unsupported_abi && seen_class_ids.insert(class_id) { let target = if owner == *class_name && ctx .pshape_methods @@ -61,11 +75,32 @@ fn direct_candidates(ctx: &FnCtx<'_>, property: &str) -> Vec { } else { public_target.clone() }; + // Imported class stubs mint caller-local layout metadata; + // that ShapeId cannot match instances allocated by the + // producer. Prefer the producer's published slot whenever + // an own-method capability identifies this class id. + let shape = ctx + .short_spread_method_candidates + .get(property) + .and_then(|candidates| { + candidates + .iter() + .find(|candidate| candidate.class_id == class_id) + }) + .map(|candidate| CandidateShape::Foreign { + cache_key: format!("#short-spread:{}", candidate.target), + shape_id_global: candidate.shape_id_global.clone(), + }) + .unwrap_or_else(|| CandidateShape::Local { + class_name: class_name.clone(), + keys_global: keys_global.clone(), + }); out.push(DirectCandidate { class_id, - class_name: class_name.clone(), target, declared_count: ctx.method_param_counts.get(&key).copied().unwrap_or(0), + shape, + needs_declare: false, }); if out.len() == MAX_METHOD_ARMS { return out; @@ -79,9 +114,54 @@ fn direct_candidates(ctx: &FnCtx<'_>, property: &str) -> Vec { .and_then(|class| class.extends_name.clone()); } } + if let Some(reverse_candidates) = ctx.short_spread_method_candidates.get(property) { + for candidate in reverse_candidates { + if !seen_class_ids.insert(candidate.class_id) { + continue; + } + out.push(DirectCandidate { + class_id: candidate.class_id, + target: candidate.target.clone(), + declared_count: candidate.declared_count, + shape: CandidateShape::Foreign { + cache_key: format!("#short-spread:{}", candidate.target), + shape_id_global: candidate.shape_id_global.clone(), + }, + needs_declare: true, + }); + if out.len() == MAX_METHOD_ARMS { + break; + } + } + } out } +fn load_candidate_shape(ctx: &mut FnCtx<'_>, candidate: &DirectCandidate) -> String { + match &candidate.shape { + CandidateShape::Local { + class_name, + keys_global, + } => crate::typed_shape::load_class_shape_id(ctx, class_name, keys_global), + CandidateShape::Foreign { + cache_key, + shape_id_global, + } => { + let slot = if let Some(slot) = ctx.class_shape_slots.get(cache_key) { + slot.clone() + } else { + let slot = ctx + .func + .entry_init_load_global(shape_id_global, crate::types::I32); + ctx.class_shape_slots + .insert(cache_key.clone(), slot.clone()); + slot + }; + ctx.block().load(I32, &slot) + } + } +} + fn first_element_ptr(ctx: &mut FnCtx<'_>, alloca: &str, count: usize) -> String { let ptr = ctx.block().next_reg(); ctx.block().emit_raw(format!( @@ -111,21 +191,44 @@ pub(crate) fn try_lower<'f, 'e>( if candidates.is_empty() { return Ok(None); } + for candidate in &candidates { + if candidate.needs_declare { + ctx.pending_declares.push(( + candidate.target.clone(), + DOUBLE, + vec![DOUBLE; candidate.declared_count + 1], + )); + } + } // Receiver, fixed arguments, then the final spread expression: exactly the - // ECMAScript evaluation order and exactly once each. One open group keeps - // every pointer-bearing value current through both CFG diamonds and the - // allocating generic fallback. - let mut roots = crate::rooting::open_rooted_group(args.len() + 1); - let recv_root = roots.lower(ctx, object, true)?; - let mut fixed_roots = Vec::with_capacity(args.len().saturating_sub(1)); + // ECMAScript evaluation order and exactly once each. Root an evaluated + // operand only across a *later operand evaluation* that can collect. Both + // guards below are non-allocating and a successful direct call consumes + // the values, so eagerly retaining every operand through the dispatch + // diamond put three root barriers in perform-ecs's reset loop for no + // safety gain. A miss installs its own cold, branch-local roots below. + let mut operand_exprs = Vec::with_capacity(args.len() + 1); + operand_exprs.push(object); for arg in &args[..args.len() - 1] { let CallArg::Expr(expr) = arg else { unreachable!() }; - fixed_roots.push(roots.lower(ctx, expr, true)?); + operand_exprs.push(expr); } - let spread_root = roots.lower(ctx, spread_expr, true)?; + operand_exprs.push(spread_expr); + let mut roots = crate::rooting::open_rooted_group(operand_exprs.len()); + let mut operand_roots = Vec::with_capacity(operand_exprs.len()); + for (index, expr) in operand_exprs.iter().enumerate() { + let collects = crate::rooting::any_operand_may_collect( + ctx, + operand_exprs[index + 1..].iter().copied(), + ); + operand_roots.push(roots.lower(ctx, expr, collects)?); + } + let recv_root = operand_roots[0]; + let fixed_roots = &operand_roots[1..operand_roots.len() - 1]; + let spread_root = operand_roots[operand_roots.len() - 1]; // Re-read once below all operand evaluation. The two guards from here to a // direct call are non-allocating; fallback re-reads again after its @@ -160,14 +263,7 @@ pub(crate) fn try_lower<'f, 'e>( ctx.current_block = method_probe_idx; let expected_shapes: Vec = candidates .iter() - .map(|candidate| { - let keys = ctx - .class_keys_globals - .get(&candidate.class_name) - .expect("candidate required a keys global") - .clone(); - crate::typed_shape::load_class_shape_id(ctx, &candidate.class_name, &keys) - }) + .map(|candidate| load_candidate_shape(ctx, candidate)) .collect(); let key_idx = ctx.strings.intern(property); let entry = ctx.strings.entry(key_idx); @@ -270,23 +366,36 @@ pub(crate) fn try_lower<'f, 'e>( // js_native_call_method_apply_by_id so overrides and wrong receivers retain // the generic semantics. ctx.current_block = fallback_idx; - let (fixed_ptr, fixed_len) = if fixed_roots.is_empty() { + // No allocation has run since the fast operands were re-read. Publish all + // of them only on this cold branch, immediately before the materializer's + // first collection point. This group nests above `roots` and is released + // before the merge, preserving temp-root stack order on both branches. + let mut fallback_roots = crate::rooting::open_rooted_group(args.len() + 1); + let fallback_recv_root = fallback_roots.adopt_emitted(ctx, Repr::Boxed, &fast_recv, true); + let fallback_fixed_roots: Vec<_> = fast_fixed + .iter() + .map(|value| fallback_roots.adopt_emitted(ctx, Repr::Boxed, value, true)) + .collect(); + let fallback_spread_root = fallback_roots.adopt_emitted(ctx, Repr::Boxed, &fast_spread, true); + let (fixed_ptr, fixed_len) = if fallback_fixed_roots.is_empty() { ("null".to_string(), "0".to_string()) } else { - let fixed_alloca = ctx.func.alloca_entry_array(DOUBLE, fixed_roots.len()); - for (index, &root) in fixed_roots.iter().enumerate() { - let value = roots.reread(ctx, root)?; + let fixed_alloca = ctx + .func + .alloca_entry_array(DOUBLE, fallback_fixed_roots.len()); + for (index, &root) in fallback_fixed_roots.iter().enumerate() { + let value = fallback_roots.reread_emitted(ctx, root); let slot = ctx .block() .gep(DOUBLE, &fixed_alloca, &[(I64, &index.to_string())]); ctx.block().store(DOUBLE, &value, &slot); } ( - first_element_ptr(ctx, &fixed_alloca, fixed_roots.len()), - fixed_roots.len().to_string(), + first_element_ptr(ctx, &fixed_alloca, fallback_fixed_roots.len()), + fallback_fixed_roots.len().to_string(), ) }; - let fallback_spread = roots.reread(ctx, spread_root)?; + let fallback_spread = fallback_roots.reread_emitted(ctx, fallback_spread_root); let args_array = ctx.block().call( I64, "js_spread_tail_fallback_args", @@ -296,7 +405,7 @@ pub(crate) fn try_lower<'f, 'e>( (DOUBLE, &fallback_spread), ], ); - let fallback_recv = roots.reread(ctx, recv_root)?; + let fallback_recv = fallback_roots.reread_emitted(ctx, fallback_recv_root); let dispatch_global = ctx.strings.static_dispatch_global(key_idx); let method_id = crate::strings::emit_static_dispatch_id(ctx.block(), &dispatch_global); let fallback_value = ctx.block().call( @@ -308,6 +417,7 @@ pub(crate) fn try_lower<'f, 'e>( (I64, &args_array), ], ); + fallback_roots.release(ctx); let fallback_after = ctx.block().label.clone(); ctx.block().br(&merge_label); phi_inputs.push((fallback_value, fallback_after)); @@ -344,6 +454,9 @@ pub(crate) fn try_lower<'f, 'e>( .to_string(), "method_identity_guard=js_method_direct_shape_class(class_id,shape_id,invalidation_slot)" .to_string(), + "candidate_scope=whole_program_producer_capabilities".to_string(), + "operand_roots=collecting_evaluation_suffix_only;fallback_roots=guard_miss_only" + .to_string(), "generic_fallback=js_spread_tail_fallback_args+js_native_call_method_apply_by_id" .to_string(), ], diff --git a/crates/perry-codegen/src/expr/call_spread_short_tests.rs b/crates/perry-codegen/src/expr/call_spread_short_tests.rs index 03122baba1..f3c5014cd1 100644 --- a/crates/perry-codegen/src/expr/call_spread_short_tests.rs +++ b/crates/perry-codegen/src/expr/call_spread_short_tests.rs @@ -113,6 +113,28 @@ fn emit() -> String { .expect("LLVM IR is UTF-8") } +fn emit_reverse_dependency_consumer() -> String { + let producer = fixture(); + let mut consumer = fixture(); + consumer.name = "generic_ecs.ts".to_string(); + consumer.classes.clear(); + let mut by_method = std::collections::HashMap::new(); + for candidate in crate::short_spread_method_capabilities(&producer) { + by_method + .entry(candidate.method_name.clone()) + .or_insert_with(Vec::new) + .push(candidate); + } + let opts = crate::CompileOptions { + emit_ir_only: true, + short_spread_method_candidates: std::sync::Arc::new(by_method), + object_literal_method_candidates: std::sync::Arc::default(), + ..Default::default() + }; + String::from_utf8(crate::compile_module(&consumer, opts).expect("consumer compiles")) + .expect("LLVM IR is UTF-8") +} + fn function_body<'a>(ir: &'a str, fragment: &str) -> &'a str { let name = ir .find(fragment) @@ -145,6 +167,13 @@ fn every_short_packed_arity_calls_reset_directly_without_apply() { let invoke = function_body(&ir, "__invoke("); assert!(invoke.contains("call i32 @js_short_packed_spread_values(")); assert!(invoke.contains("call i32 @js_method_direct_shape_class(")); + let fast_prefix = &invoke[..invoke + .find("\nshort_spread.method_probe") + .expect("method-probe block")]; + assert!( + !fast_prefix.contains("js_write_barrier_root_nanbox"), + "non-collecting operand suffixes must not install eager fast-path roots\n{fast_prefix}" + ); for candidate in 0..2 { for arity in 0..=4 { @@ -169,19 +198,40 @@ fn every_short_packed_arity_calls_reset_directly_without_apply() { fn guard_misses_retain_one_full_iterator_apply_fallback() { let ir = emit(); let invoke = function_body(&ir, "__invoke("); - let fallback = named_block(invoke, "short_spread.fallback"); + assert!(invoke.contains("short_spread.fallback")); assert_eq!( - fallback + invoke .matches("call i64 @js_spread_tail_fallback_args(") .count(), 1, - "fallback must materialize fixed+spread exactly once\n{fallback}" + "fallback must materialize fixed+spread exactly once\n{invoke}" ); assert_eq!( - fallback + invoke .matches("call double @js_native_call_method_apply_by_id(") .count(), 1, - "fallback must retain dynamic method apply\n{fallback}" + "fallback must retain dynamic method apply\n{invoke}" ); } + +#[test] +fn reverse_dependency_capabilities_activate_in_a_generic_consumer() { + let ir = emit_reverse_dependency_consumer(); + let invoke = function_body(&ir, "__invoke("); + assert!( + invoke.contains("call i32 @js_short_packed_spread_values("), + "the generic consumer has no local concrete class, so activation proves the producer-to-consumer capability flow\n{invoke}" + ); + assert!(invoke.contains("call i32 @js_method_direct_shape_class(")); + assert!(invoke.contains("@perry_method_issue_8772_short_spread_ts__Position__reset(")); + assert!(invoke.contains("@perry_method_issue_8772_short_spread_ts__Velocity__reset(")); + assert!(ir.contains( + "@perry_class_shape_id_issue_8772_short_spread_ts__Position = external global i32" + )); + assert!(ir.contains( + "@perry_class_shape_id_issue_8772_short_spread_ts__Velocity = external global i32" + )); + assert!(invoke.contains("short_spread.fallback")); + assert!(invoke.contains("call double @js_native_call_method_apply_by_id(")); +} diff --git a/crates/perry-codegen/src/expr/class_field_barrier_tests.rs b/crates/perry-codegen/src/expr/class_field_barrier_tests.rs index 8224603cdc..db0634bf17 100644 --- a/crates/perry-codegen/src/expr/class_field_barrier_tests.rs +++ b/crates/perry-codegen/src/expr/class_field_barrier_tests.rs @@ -79,6 +79,8 @@ pub(super) fn ir_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), 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 45567ab84c..fd312182ac 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 @@ -72,6 +72,8 @@ fn ir_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/expr/conforming_layout_note_tests.rs b/crates/perry-codegen/src/expr/conforming_layout_note_tests.rs index 5f50e3111b..650c977f58 100644 --- a/crates/perry-codegen/src/expr/conforming_layout_note_tests.rs +++ b/crates/perry-codegen/src/expr/conforming_layout_note_tests.rs @@ -53,6 +53,8 @@ fn ir_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index e5235a0ce5..108e2052a0 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -708,6 +708,14 @@ 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>, + /// Whole-program reverse capabilities for guarded short-spread method + /// calls. See `CompileOptions::short_spread_method_candidates`. + pub short_spread_method_candidates: + &'a std::collections::HashMap>, + /// Whole-program exported object-literal candidates for dynamic receiver + /// calls. See `CompileOptions::object_literal_method_candidates`. + pub object_literal_method_candidates: + &'a std::collections::HashMap>, /// FFI manifest: `name -> (params, return)` from `package.json` /// `nativeLibrary.functions`. Descriptors use the shared native-library /// ABI vocabulary. `lower_call` consults @@ -2211,12 +2219,27 @@ impl<'a> FnCtx<'a> { pub(crate) fn ptr_shape_argument_route_fact( &self, e: &perry_hir::Expr, - ) -> Option<&crate::collectors::PtrShapeLocal> { + ) -> Option<(&crate::collectors::PtrShapeLocal, bool)> { match e { // Ordinary native facts are containment proofs. A selected clone - // parameter inherits the same contract from the only routes that - // can call that clone. - perry_hir::Expr::LocalGet(id) => self.ptr_shape_local_fact(*id), + // parameter inherits the class fact from its caller's guard, but + // forwarded clone parameters retain an explicit guard/fallback at + // the next route. A fresh local's provenance+containment proof is + // already stronger than re-reading the same runtime header here. + perry_hir::Expr::LocalGet(id) => self + .proven_shape_params + .get(id) + .map(|fact| (fact, true)) + .or_else(|| { + self.native_facts + .guarded_argument_route_local(*id) + .map(|fact| (fact, false)) + }) + .or_else(|| { + self.native_facts + .shape_proven_ptr_local(*id) + .map(|fact| (fact, false)) + }), // `proven_this` may come from a runtime receiver guard rather than // containment, so it cannot justify an argument clone route. _ => None, diff --git a/crates/perry-codegen/src/expr/property_get/tests.rs b/crates/perry-codegen/src/expr/property_get/tests.rs index 30a8572285..8b4fc7e6d7 100644 --- a/crates/perry-codegen/src/expr/property_get/tests.rs +++ b/crates/perry-codegen/src/expr/property_get/tests.rs @@ -35,6 +35,8 @@ fn ir_opts(debug_locations: bool, module_source: Option<&str>) -> CompileOptions namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/gc_call_effects.rs b/crates/perry-codegen/src/gc_call_effects.rs index 7eeeb089ba..ed0c3b99e9 100644 --- a/crates/perry-codegen/src/gc_call_effects.rs +++ b/crates/perry-codegen/src/gc_call_effects.rs @@ -54,6 +54,9 @@ pub(crate) fn classify_direct_callee(name: &str) -> GcCallEffect { | "js_typed_feedback_numeric_array_index_set_guard" | "js_typed_feedback_numeric_array_push_guard" | "js_array_numeric_value_to_raw_f64" + // `array/subclass.rs`: scalar descriptor/header comparison only. It + // neither allocates nor enters user code; a miss returns zero. + | "js_packed_arraylike_loop_revalidate_live" // `gc/roots/temp_roots.rs`: TLS vector operations and an incremental // marking barrier only. They never run a Perry collection. | "js_gc_temp_root_push" @@ -130,6 +133,14 @@ pub(crate) fn classify_direct_callee(name: &str) -> GcCallEffect { // two observe guards above; unlike the excluded get/set wrappers it does // NOT perform an object get/set. In `NONCOLLECTING` (the audit authority). | "js_typed_feedback_closure_direct_call_guard" + // #8775: speculation-safe closure header/function-pointer reads only. + // Whole-program object-literal calls use this after arity/rest were + // proven statically, so it has no registry or feedback side effects. + | "js_closure_exact_func_guard" + // #8775: cold own-method IC prime. Validated object/shape/key/slot and + // closure reads plus one compiler-private cache store; no allocation, + // JS re-entry, throw, or Perry-GC trigger. + | "js_object_own_method_cache_miss" // Same family, audited 2026-08-04: under `diagnostics` it reads an env // var, serialises the counters with serde_json and writes a file; // without the feature the body is empty. No Perry allocation, no @@ -757,6 +768,10 @@ mod tests { /// - `js_typed_feedback_closure_direct_call_guard` — header/registry /// reads + a `guard_observe` whose only allocation is a Rust `Vec::push` /// (cannot arm a Perry-GC trigger); no re-entry, no throw. + /// - `js_closure_exact_func_guard` — the same safe header reads without + /// registry access or feedback recording (#8775). + /// - `js_object_own_method_cache_miss` — cold shape/key/closure + /// validation plus a compiler-private token store (#8775). /// - `js_implicit_this_get` — a bare `IMPLICIT_THIS` `Cell` read, the /// shape of the already-admitted `js_implicit_this_set`. /// - `js_tdz_suppress_begin`/`_end` — a thread-local `Cell` inc/dec. @@ -764,6 +779,8 @@ mod tests { fn issue_8596_tls_and_feedback_guards_cannot_collect() { for name in [ "js_typed_feedback_closure_direct_call_guard", + "js_closure_exact_func_guard", + "js_object_own_method_cache_miss", "js_implicit_this_get", "js_tdz_suppress_begin", "js_tdz_suppress_end", diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index fb9f1454f5..4e9dd6fa9d 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -71,9 +71,10 @@ pub(crate) mod typed_shape; pub mod types; pub use codegen::{ - compile_module, resolve_target_triple, AppMetadata, CompileOptions, - ExportedObjectLiteralCapability, FpContractMode, ImportedClass, ImportedObjectLiteral, - ImportedObjectLiteralMethod, NamespaceEntry, NamespaceEntryKind, + compile_module, resolve_target_triple, short_spread_method_capabilities, AppMetadata, + CompileOptions, ExportedObjectLiteralCapability, FpContractMode, ImportedClass, + ImportedObjectLiteral, ImportedObjectLiteralMethod, NamespaceEntry, NamespaceEntryKind, + ObjectLiteralMethodCandidate, ShortSpreadMethodCandidate, }; pub use collectors::CjsPreambleCensus; 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 23925881b5..f0366a656e 100644 --- a/crates/perry-codegen/src/lower_call/alloc_hot_tests.rs +++ b/crates/perry-codegen/src/lower_call/alloc_hot_tests.rs @@ -75,6 +75,8 @@ fn ir_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/lower_call/method_override.rs b/crates/perry-codegen/src/lower_call/method_override.rs index 930746ab05..533fbeebfb 100644 --- a/crates/perry-codegen/src/lower_call/method_override.rs +++ b/crates/perry-codegen/src/lower_call/method_override.rs @@ -203,11 +203,12 @@ pub(super) fn emit_pshape_argument_dispatch( let plan = ctx.pshape_arg_methods.get(&key)?.clone(); let clone_fn = crate::collectors::pshape_args_method_name(direct_fn); - let mut guarded = Vec::with_capacity(plan.args.len()); + let mut routed = Vec::with_capacity(plan.args.len()); for arg in &plan.args { let direct_index = arg.param_index + 1; let source_arg = source_args.get(arg.param_index)?; - let caller_fact = ctx.ptr_shape_argument_route_fact(source_arg)?; + let (caller_fact, requires_runtime_guard) = + ctx.ptr_shape_argument_route_fact(source_arg)?; if caller_fact.class_name != arg.fact.class_name { return None; } @@ -218,14 +219,64 @@ pub(super) fn emit_pshape_argument_dispatch( .filter(|(index, _)| *index != direct_index) .map(|(_, (_, other))| (*other).to_string()) .collect(); + routed.push((arg.clone(), value, non_alias_values, requires_runtime_guard)); + } + if routed.is_empty() { + return None; + } + + // A native fresh-local fact proves exact allocation class, unchanged + // shape, and non-aliasing up to this call. Re-reading tag/range/GC header, + // class, ShapeId, and every formal-alias comparison is redundant and was + // slower than the one field IC the clone removes in perform-ecs. Keep the + // guarded path below for forwarded clone parameters, whose fact originates + // at a dynamic caller boundary. + if routed.iter().all(|route| !route.3) { + let result = ctx.block().call(DOUBLE, &clone_fn, direct_arg_slices); + let mut notes = vec![ + format!("argument_clone={clone_fn}"), + format!("generic_method={generic_fn}"), + format!("receiver_class={receiver_class_name}"), + format!("method={property}"), + "argument_abi=tagged_js_value_shadow_rooted".to_string(), + "argument_guard=elided_by_fresh_provenance_and_containment".to_string(), + "wrong_shape_route=generic_method_before_clone_selection".to_string(), + ]; + for (arg, _, _, _) in &routed { + notes.push(format!("argument_index={}", arg.param_index)); + notes.push(format!("argument_class={}", arg.fact.class_name)); + notes.push("argument_alias_proof=caller_containment".to_string()); + notes.push("argument_provenance=fresh_exact_class".to_string()); + } + ctx.record_lowered_value( + "MethodCall", + None, + "proven_shape_argument_method_call", + &LoweredValue::js_value(result.clone()), + None, + None, + None, + false, + false, + notes, + ); + return Some(result); + } + + let mut guarded = Vec::with_capacity(routed.len()); + for (arg, value, non_alias_values, requires_runtime_guard) in routed { let class_id = *ctx.class_ids.get(&arg.fact.class_name)?; let keys_global = ctx.class_keys_globals.get(&arg.fact.class_name)?.clone(); let shape_id = crate::typed_shape::load_class_shape_id(ctx, &arg.fact.class_name, &keys_global); - guarded.push((arg.clone(), value, non_alias_values, class_id, shape_id)); - } - if guarded.is_empty() { - return None; + guarded.push(( + arg, + value, + non_alias_values, + class_id, + shape_id, + requires_runtime_guard, + )); } let fast_idx = ctx.new_block("pshape_arg.fast"); @@ -238,7 +289,7 @@ pub(super) fn emit_pshape_argument_dispatch( let fallback_label = ctx.block_label(fallback_idx); let merge_label = ctx.block_label(merge_idx); - for (index, (_, value, non_alias_values, class_id, shape_id)) in guarded.iter().enumerate() { + for (index, (_, value, non_alias_values, class_id, shape_id, _)) in guarded.iter().enumerate() { let pass_label = intermediate_idxs .get(index) .map(|block| ctx.block_label(*block)) @@ -283,7 +334,7 @@ pub(super) fn emit_pshape_argument_dispatch( "argument_abi=tagged_js_value_shadow_rooted".to_string(), "guard_failure_fallback=generic_method".to_string(), ]; - for (arg, _, _, _, _) in &guarded { + for (arg, _, _, _, _, _) in &guarded { notes.push(format!("argument_index={}", arg.param_index)); notes.push(format!("argument_class={}", arg.fact.class_name)); notes.push("argument_guard=exact_class_and_shape".to_string()); diff --git a/crates/perry-codegen/src/lower_call/property_get.rs b/crates/perry-codegen/src/lower_call/property_get.rs index 993c1f811a..7fc22aa15f 100644 --- a/crates/perry-codegen/src/lower_call/property_get.rs +++ b/crates/perry-codegen/src/lower_call/property_get.rs @@ -231,5 +231,15 @@ pub fn try_lower_property_get_method_call( return Ok(Some(value)); } + if let Some(value) = imported_object::try_lower_dynamic_object_method_call( + ctx, + object, + property, + args, + call_byte_offset, + )? { + return Ok(Some(value)); + } + Ok(None) } diff --git a/crates/perry-codegen/src/lower_call/property_get/imported_object.rs b/crates/perry-codegen/src/lower_call/property_get/imported_object.rs index f9712c0ac2..d4a70bdbba 100644 --- a/crates/perry-codegen/src/lower_call/property_get/imported_object.rs +++ b/crates/perry-codegen/src/lower_call/property_get/imported_object.rs @@ -3,13 +3,14 @@ use anyhow::Result; use perry_hir::Expr; -use crate::expr::{ - emit_typed_feedback_register_site, lower_expr, unbox_to_i64, FnCtx, TypedFeedbackContract, - TypedFeedbackKind, -}; +use crate::expr::{lower_expr, unbox_to_i64, FnCtx}; use crate::native_value::LoweredValue; use crate::rooting::{any_operand_may_collect, open_rooted_group, Repr}; -use crate::types::{DOUBLE, I32, I64, I8, PTR}; +use crate::types::{DOUBLE, I1, I32, I64, I8, PTR}; + +const POINTER_TAG_HI16: &str = "32765"; // 0x7FFD +const GC_OBJECT_METHOD_GUARD_MASK_I32: &str = "142639359"; // 0x0880_80ff +const GC_TYPE_OBJECT: &str = "2"; fn receiver_binding(ctx: &FnCtx<'_>, object: &Expr) -> Option { match object { @@ -33,6 +34,127 @@ fn spill_args(ctx: &mut FnCtx<'_>, args: &[String]) -> (String, String) { (buf, args.len().to_string()) } +/// Emit a monomorphic own-method cache that can relearn an append-only shape +/// successor after an adapter's `setup()` adds state fields. `entry_idx` is +/// reached only after exact exported-object identity matched. On return the +/// current block is the direct arm and the result is the validated raw closure +/// handle to pass to the producer body. +#[allow(clippy::too_many_arguments)] +fn emit_cached_own_method_guard( + ctx: &mut FnCtx<'_>, + entry_idx: usize, + recv: &str, + recv_bits: &str, + expected_class_id: u32, + field_index: u32, + property: &str, + closure_symbol: &str, + miss_label: &str, +) -> String { + let key_idx = ctx.strings.intern(property); + let key_entry = ctx.strings.entry(key_idx); + let bytes_global = format!("@{}", key_entry.bytes_global); + let name_len = key_entry.byte_len.to_string(); + + let cache_site = ctx.ic_site_counter; + ctx.ic_site_counter += 1; + let cache_name = crate::expr::inline_cache_global_name(ctx, cache_site); + ctx.ic_globals.push(cache_name.clone()); + let cache_ref = format!("@{cache_name}"); + + let deref_idx = ctx.new_block("object_method_cache.deref"); + let fast_idx = ctx.new_block("object_method_cache.fast"); + let cold_idx = ctx.new_block("object_method_cache.revalidate"); + let direct_idx = ctx.new_block("object_method_cache.direct"); + let deref_label = ctx.block_label(deref_idx); + let fast_label = ctx.block_label(fast_idx); + let cold_label = ctx.block_label(cold_idx); + let direct_label = ctx.block_label(direct_idx); + + ctx.current_block = entry_idx; + let heap_floor = + crate::target_layout::heap_addr_lower_bound_inclusive(ctx.target_triple).to_string(); + let heap_ceiling = + crate::target_layout::heap_addr_upper_bound_exclusive(ctx.target_triple).to_string(); + let recv_handle = ctx + .block() + .and(I64, recv_bits, crate::nanbox::POINTER_MASK_I64); + let tag = ctx.block().lshr(I64, recv_bits, "48"); + let tagged = ctx.block().icmp_eq(I64, &tag, POINTER_TAG_HI16); + let above_floor = ctx.block().icmp_uge(I64, &recv_handle, &heap_floor); + let below_ceiling = ctx.block().icmp_ult(I64, &recv_handle, &heap_ceiling); + let in_range = ctx.block().and(I1, &above_floor, &below_ceiling); + let safe = ctx.block().and(I1, &tagged, &in_range); + ctx.block().cond_br(&safe, &deref_label, miss_label); + + ctx.current_block = deref_idx; + let object_ptr = ctx.block().inttoptr(I64, &recv_handle); + let gc_header_ptr = ctx.block().gep(I8, &object_ptr, &[(I64, "-8")]); + let gc_header = ctx.block().load(I32, &gc_header_ptr); + let guarded_gc_bits = ctx + .block() + .and(I32, &gc_header, GC_OBJECT_METHOD_GUARD_MASK_I32); + let gc_header_ok = ctx.block().icmp_eq(I32, &guarded_gc_bits, GC_TYPE_OBJECT); + let live_class_shape = ctx.block().load(I64, &object_ptr); + let cache_token_ptr = ctx.block().gep(I64, &cache_ref, &[(I64, "0")]); + let cached_class_shape = ctx.block().load(I64, &cache_token_ptr); + let cache_populated = ctx.block().icmp_ne(I64, &cached_class_shape, "0"); + let shape_matches = ctx + .block() + .icmp_eq(I64, &live_class_shape, &cached_class_shape); + let cache_hit = ctx.block().and(I1, &gc_header_ok, &cache_populated); + let cache_hit = ctx.block().and(I1, &cache_hit, &shape_matches); + ctx.block().cond_br(&cache_hit, &fast_label, &cold_label); + + ctx.current_block = fast_idx; + let header_skip = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); + let fields = ctx.block().gep(I8, &object_ptr, &[(I64, &header_skip)]); + let slot = ctx + .block() + .gep(DOUBLE, &fields, &[(I64, &field_index.to_string())]); + let closure_value = ctx.block().load(DOUBLE, &slot); + let guard = ctx.block().call( + I32, + "js_closure_exact_func_guard", + &[ + (DOUBLE, &closure_value), + (PTR, &format!("@{closure_symbol}")), + ], + ); + let guard_passes = ctx.block().icmp_ne(I32, &guard, "0"); + let fast_handle = unbox_to_i64(ctx.block(), &closure_value); + let fast_end = ctx.block().label.clone(); + ctx.block() + .cond_br(&guard_passes, &direct_label, &cold_label); + + ctx.current_block = cold_idx; + let cold_handle = ctx.block().call( + I64, + "js_object_own_method_cache_miss", + &[ + (DOUBLE, recv), + (I32, &expected_class_id.to_string()), + (I32, &field_index.to_string()), + (PTR, &bytes_global), + (I64, &name_len), + (PTR, &format!("@{closure_symbol}")), + (PTR, &cache_token_ptr), + ], + ); + let cold_passes = ctx.block().icmp_ne(I64, &cold_handle, "0"); + let cold_end = ctx.block().label.clone(); + ctx.block().cond_br(&cold_passes, &direct_label, miss_label); + + ctx.current_block = direct_idx; + ctx.block().phi( + I64, + &[ + (fast_handle.as_str(), fast_end.as_str()), + (cold_handle.as_str(), cold_end.as_str()), + ], + ) +} + pub(super) fn try_lower_imported_object_method_call( ctx: &mut FnCtx<'_>, object: &Expr, @@ -61,14 +183,6 @@ pub(super) fn try_lower_imported_object_method_call( else { return Ok(None); }; - let Some(keys_global) = ctx - .class_keys_globals - .get(&capability.receiver_class_name) - .cloned() - else { - return Ok(None); - }; - // JavaScript evaluates the receiver before arguments. Keep that value (and // each argument) rooted through both branches, then run all guards after // argument evaluation so a mutating argument cannot slip past the proof. @@ -84,29 +198,18 @@ pub(super) fn try_lower_imported_object_method_call( let lowered_args = roots.reread_all(ctx)?; let key_index = ctx.strings.intern(property); - let key_entry = ctx.strings.entry(key_index); - let method_guard_slot = (key_entry.dispatch_hash & 0xffff).to_string(); let dispatch_global = ctx.strings.static_dispatch_global(key_index); - let expected_shape_id = - crate::typed_shape::load_class_shape_id(ctx, &capability.receiver_class_name, &keys_global); - let closure_symbol = format!( - "perry_closure_{}__{}", - capability.source_prefix, method.func_id - ); + let closure_symbol = method.target.clone(); let mut closure_params = Vec::with_capacity(method.param_count + 1); closure_params.push(I64); closure_params.extend(std::iter::repeat_n(DOUBLE, method.param_count)); ctx.pending_declares .push((closure_symbol.clone(), DOUBLE, closure_params)); - let shape_idx = ctx.new_block("imported_object.shape_guard"); - let method_idx = ctx.new_block("imported_object.method_guard"); - let direct_idx = ctx.new_block("imported_object.direct"); + let cache_idx = ctx.new_block("imported_object.cache_guard"); let fallback_idx = ctx.new_block("imported_object.fallback"); let merge_idx = ctx.new_block("imported_object.merge"); - let shape_label = ctx.block_label(shape_idx); - let method_label = ctx.block_label(method_idx); - let direct_label = ctx.block_label(direct_idx); + let cache_label = ctx.block_label(cache_idx); let fallback_label = ctx.block_label(fallback_idx); let merge_label = ctx.block_label(merge_idx); @@ -121,57 +224,19 @@ pub(super) fn try_lower_imported_object_method_call( let expected_bits = ctx.block().bitcast_double_to_i64(&expected_receiver); let receiver_matches = ctx.block().icmp_eq(I64, &recv_bits, &expected_bits); ctx.block() - .cond_br(&receiver_matches, &shape_label, &fallback_label); + .cond_br(&receiver_matches, &cache_label, &fallback_label); - ctx.current_block = shape_idx; - crate::lower_call::method_override::emit_inline_direct_method_shape_guard( + let closure_handle = emit_cached_own_method_guard( ctx, + cache_idx, &recv, - &expected_class_id.to_string(), - &expected_shape_id, - &method_guard_slot, - &method_label, + &recv_bits, + expected_class_id, + method.field_index, + property, + &closure_symbol, &fallback_label, ); - - // The exact shape proves the own data slot. Load it directly, then validate - // the live closure's underlying function identity. Replacement, deletion, - // accessors, and bound/arrow substitutes all fail one of these guards. - ctx.current_block = method_idx; - let closure_value = { - let header_skip = - crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); - let blk = ctx.block(); - let recv_handle = blk.and(I64, &recv_bits, crate::nanbox::POINTER_MASK_I64); - let object_ptr = blk.inttoptr(I64, &recv_handle); - let fields = blk.gep(I8, &object_ptr, &[(I64, &header_skip)]); - let slot = blk.gep(DOUBLE, &fields, &[(I64, &method.field_index.to_string())]); - blk.load(DOUBLE, &slot) - }; - let site_id = emit_typed_feedback_register_site( - ctx, - TypedFeedbackKind::ClosureCall, - &format!("imported-object:{binding}.{property}"), - TypedFeedbackContract::closure_direct_call(), - ); - let arity = method.param_count.to_string(); - let guard = ctx.block().call( - I32, - "js_typed_feedback_closure_direct_call_guard", - &[ - (I64, &site_id), - (DOUBLE, &closure_value), - (PTR, &format!("@{closure_symbol}")), - (I32, &arity), - (I32, &arity), - ], - ); - let guard_passes = ctx.block().icmp_ne(I32, &guard, "0"); - ctx.block() - .cond_br(&guard_passes, &direct_label, &fallback_label); - - ctx.current_block = direct_idx; - let closure_handle = unbox_to_i64(ctx.block(), &closure_value); let mut direct_args: Vec<(crate::types::LlvmType, &str)> = Vec::with_capacity(lowered_args.len() + 1); direct_args.push((I64, &closure_handle)); @@ -227,7 +292,172 @@ pub(super) fn try_lower_imported_object_method_call( format!("method={property}"), format!("selected_method_identity={closure_symbol}"), format!("field_index={}", method.field_index), - "guards=receiver_identity,exact_shape,own_data_slot,function_identity".to_string(), + "guards=receiver_identity,live_shape_cache,own_key_slot,function_identity".to_string(), + "append_only_shape_successors=revalidated_and_cached".to_string(), + "generic_dispatch_fallback=js_native_call_method_by_id".to_string(), + ], + ); + Ok(Some(result)) +} + +/// Select a stable exported object-literal method from an otherwise dynamic +/// local receiver. This is needed when the receiver crosses a module boundary +/// through a parameter (`suite.perform(library)`): there is no import binding +/// in the suite module, but whole-program producer metadata still gives us a +/// finite set of exact identities to guard. +pub(super) fn try_lower_dynamic_object_method_call( + ctx: &mut FnCtx<'_>, + object: &Expr, + property: &str, + args: &[Expr], + call_byte_offset: u32, +) -> Result> { + const MAX_ARMS: usize = 8; + + // Keep this reverse-flow slice deliberately narrow. Known class, builtin, + // and direct-import receivers have already had their more specific routes. + if !matches!(object, Expr::LocalGet(_)) || receiver_binding(ctx, object).is_some() { + return Ok(None); + } + let Some(by_name) = ctx.object_literal_method_candidates.get(property) else { + return Ok(None); + }; + let candidates: Vec<_> = by_name + .iter() + .filter(|candidate| candidate.method.param_count == args.len()) + .cloned() + .collect(); + if candidates.is_empty() || candidates.len() > MAX_ARMS { + return Ok(None); + } + + // Receiver, then arguments, exactly once. All values remain rooted across + // every guard arm, the direct closure body, and the generic fallback. + let mut roots = open_rooted_group(args.len() + 1); + let recv = lower_expr(ctx, object)?; + let receiver_collects = any_operand_may_collect(ctx, args.iter()); + let receiver_root = roots.adopt_emitted(ctx, Repr::Boxed, &recv, receiver_collects); + for (index, arg) in args.iter().enumerate() { + let collects = any_operand_may_collect(ctx, args[index + 1..].iter()); + roots.lower(ctx, arg, collects)?; + } + let recv = roots.reread_emitted(ctx, receiver_root); + let lowered_args = roots.reread_all(ctx)?; + let recv_bits = ctx.block().bitcast_double_to_i64(&recv); + + let key_index = ctx.strings.intern(property); + let dispatch_global = ctx.strings.static_dispatch_global(key_index); + let fallback_idx = ctx.new_block("object_candidate.fallback"); + let merge_idx = ctx.new_block("object_candidate.merge"); + let fallback_label = ctx.block_label(fallback_idx); + let merge_label = ctx.block_label(merge_idx); + let mut direct_results = Vec::with_capacity(candidates.len() + 1); + + for (index, candidate) in candidates.iter().enumerate() { + let cache_idx = ctx.new_block("object_candidate.cache_guard"); + let next_idx = + (index + 1 < candidates.len()).then(|| ctx.new_block("object_candidate.next")); + let cache_label = ctx.block_label(cache_idx); + let miss_label = next_idx + .map(|block| ctx.block_label(block)) + .unwrap_or_else(|| fallback_label.clone()); + + let source_global = format!( + "@perry_global_{}__{}", + candidate.source_prefix, candidate.source_global_id + ); + let expected_receiver = ctx.block().load(DOUBLE, &source_global); + let expected_bits = ctx.block().bitcast_double_to_i64(&expected_receiver); + let receiver_matches = ctx.block().icmp_eq(I64, &recv_bits, &expected_bits); + ctx.block() + .cond_br(&receiver_matches, &cache_label, &miss_label); + + let closure_symbol = candidate.method.target.clone(); + let mut closure_params = Vec::with_capacity(candidate.method.param_count + 1); + closure_params.push(I64); + closure_params.extend(std::iter::repeat_n(DOUBLE, candidate.method.param_count)); + ctx.pending_declares + .push((closure_symbol.clone(), DOUBLE, closure_params)); + let closure_handle = emit_cached_own_method_guard( + ctx, + cache_idx, + &recv, + &recv_bits, + candidate.class_id, + candidate.method.field_index, + property, + &closure_symbol, + &miss_label, + ); + let mut direct_args: Vec<(crate::types::LlvmType, &str)> = + Vec::with_capacity(lowered_args.len() + 1); + direct_args.push((I64, &closure_handle)); + direct_args.extend(lowered_args.iter().map(|arg| (DOUBLE, arg.as_str()))); + let direct_value = ctx.block().call(DOUBLE, &closure_symbol, &direct_args); + let direct_end = ctx.block().label.clone(); + if !ctx.block().is_terminated() { + ctx.block().br(&merge_label); + } + direct_results.push((direct_value, direct_end)); + + if let Some(next_idx) = next_idx { + ctx.current_block = next_idx; + } + } + + ctx.current_block = fallback_idx; + let method_id = crate::strings::emit_static_dispatch_id(ctx.block(), &dispatch_global); + let (args_ptr, args_len) = spill_args(ctx, &lowered_args); + crate::expr::calls::emit_call_location_at(ctx, call_byte_offset); + let fallback_value = ctx.block().call( + DOUBLE, + "js_native_call_method_by_id", + &[ + (DOUBLE, &recv), + (I64, &method_id), + (PTR, &args_ptr), + (I64, &args_len), + ], + ); + let fallback_end = ctx.block().label.clone(); + if !ctx.block().is_terminated() { + ctx.block().br(&merge_label); + } + direct_results.push((fallback_value, fallback_end)); + + ctx.current_block = merge_idx; + let incoming: Vec<_> = direct_results + .iter() + .map(|(value, block)| (value.as_str(), block.as_str())) + .collect(); + let result = ctx.block().phi(DOUBLE, &incoming); + roots.release(ctx); + let selected = candidates + .iter() + .map(|candidate| { + format!( + "{}#{}:{}", + candidate.source_prefix, candidate.source_global_id, candidate.method.func_id + ) + }) + .collect::>() + .join(","); + ctx.record_lowered_value( + "MethodCall", + None, + "whole_program_object_literal_method_direct_call", + &LoweredValue::js_value(result.clone()), + None, + None, + None, + false, + false, + vec![ + "receiver_provenance=dynamic_local_with_producer_candidates".to_string(), + format!("method={property}"), + format!("selected_method_identities={selected}"), + "guards=receiver_identity,live_shape_cache,own_key_slot,function_identity".to_string(), + "append_only_shape_successors=revalidated_and_cached".to_string(), "generic_dispatch_fallback=js_native_call_method_by_id".to_string(), ], ); 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 d819b69c58..3b86b99302 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 @@ -124,6 +124,8 @@ fn ir_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/native_root_coverage/mod.rs b/crates/perry-codegen/src/native_root_coverage/mod.rs index 25a2a615a7..05a650a1f1 100644 --- a/crates/perry-codegen/src/native_root_coverage/mod.rs +++ b/crates/perry-codegen/src/native_root_coverage/mod.rs @@ -133,6 +133,8 @@ pub(crate) fn ir_opts(target: &str, is_entry: bool) -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/root_reload.rs b/crates/perry-codegen/src/root_reload.rs index dd01493de8..ef8d8965ff 100644 --- a/crates/perry-codegen/src/root_reload.rs +++ b/crates/perry-codegen/src/root_reload.rs @@ -216,6 +216,8 @@ const NON_COLLECTING: &[&str] = &[ "js_nanbox_get_pointer", // inline-cache guards: pure reads "js_typed_feedback_closure_direct_call_guard", + "js_closure_exact_func_guard", + "js_object_own_method_cache_miss", // verified non-allocating bookkeeping stores/reads "js_closure_set_capture_bits", "js_closure_set_box_capture_ptr", diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index 707e24780d..3e62b3c098 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -231,6 +231,12 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { I32, &[I64, DOUBLE, PTR, I32, I32], ); + module.declare_function("js_closure_exact_func_guard", I32, &[DOUBLE, PTR]); + module.declare_function( + "js_object_own_method_cache_miss", + I64, + &[DOUBLE, I32, I32, PTR, I64, PTR, PTR], + ); module.declare_function( "js_typed_feedback_object_set_unboxed_f64_field", VOID, diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 17aeb903e9..d8d21bf9ff 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -723,6 +723,14 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { I64, &[DOUBLE, DOUBLE, I32, PTR], ); + // #8773: O(1) revalidation against descriptor words published by the + // complete guard. Used before later observable indexed effects without + // repeating dense-layout discovery or descriptor publication. + module.declare_function( + "js_packed_arraylike_loop_revalidate_live", + I64, + &[DOUBLE, DOUBLE, I32, PTR], + ); // Issue #957: tag-aware dynamic index write. Used by `Expr::IndexUpdate` // codegen to write back the incremented value without rebuilding the // IndexSet dispatch tree. Routes to `js_array_set_index_or_string` for diff --git a/crates/perry-codegen/src/stmt/boxed_slot_no_root_tests.rs b/crates/perry-codegen/src/stmt/boxed_slot_no_root_tests.rs index d2f98cabbe..3b3d8ef906 100644 --- a/crates/perry-codegen/src/stmt/boxed_slot_no_root_tests.rs +++ b/crates/perry-codegen/src/stmt/boxed_slot_no_root_tests.rs @@ -43,6 +43,8 @@ fn ir_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/stmt/class_field_loop_tests.rs b/crates/perry-codegen/src/stmt/class_field_loop_tests.rs index fee95a7d92..77c232353a 100644 --- a/crates/perry-codegen/src/stmt/class_field_loop_tests.rs +++ b/crates/perry-codegen/src/stmt/class_field_loop_tests.rs @@ -52,6 +52,8 @@ fn ir_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs b/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs index 1219e215f4..3a65f9e7ef 100644 --- a/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs +++ b/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs @@ -42,6 +42,8 @@ fn ir_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/stmt/prealloc_module_global_tests.rs b/crates/perry-codegen/src/stmt/prealloc_module_global_tests.rs index cfa71925fd..5443cc155f 100644 --- a/crates/perry-codegen/src/stmt/prealloc_module_global_tests.rs +++ b/crates/perry-codegen/src/stmt/prealloc_module_global_tests.rs @@ -60,6 +60,8 @@ fn ir_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/stmt/stable_packed_loop.rs b/crates/perry-codegen/src/stmt/stable_packed_loop.rs index 0a3bd01d5e..a41e5f46d2 100644 --- a/crates/perry-codegen/src/stmt/stable_packed_loop.rs +++ b/crates/perry-codegen/src/stmt/stable_packed_loop.rs @@ -114,14 +114,20 @@ fn stmt_flags(stmt: &Stmt, array_id: u32, counter_id: u32) -> (bool, bool) { /// The direct read must be in the first straight-line statement and before any /// explicit user call. Later statements may allocate or invoke callbacks: the /// next iteration reloads the root and validates before using it again. -fn body_has_safe_leading_read(body: &[Stmt], array_id: u32, counter_id: u32) -> bool { +fn body_has_safe_leading_read( + body: &[Stmt], + array_id: u32, + counter_id: u32, + allow_revalidated_later_reads: bool, +) -> bool { for (index, stmt) in body.iter().enumerate() { let (has_target, has_call) = stmt_flags(stmt, array_id, counter_id); if has_target { return !has_call - && !body[index + 1..] - .iter() - .any(|later| stmt_flags(later, array_id, counter_id).0); + && (allow_revalidated_later_reads + || !body[index + 1..] + .iter() + .any(|later| stmt_flags(later, array_id, counter_id).0)); } // Compound indexed assignments are lowered into pure receiver/key // temporaries before the source indexed read. Replaying these local @@ -304,7 +310,12 @@ fn match_candidate( // replaying the current iteration would duplicate preceding effects. } else if body.iter().any(stmt_contains_break) { Some("break_replays_current_iteration") - } else if !body_has_safe_leading_read(body, array_id, counter_id) { + } else if !body_has_safe_leading_read( + body, + array_id, + counter_id, + nested_requires_access_revalidation, + ) { Some("indexed_read_not_safe_and_leading") // LocalGet prefixes are replay-safe for a nested derived receiver, whose // guard is emitted at the indexed read. A capture is guarded at iteration @@ -500,6 +511,10 @@ fn record_artifacts(ctx: &mut FnCtx<'_>, candidate: &Candidate, receiver: &str) } if candidate.nested_derived { selected_facts.push("candidate_origin=guarded_outer_index_read".to_string()); + if candidate.nested_requires_access_revalidation { + selected_facts + .push("nested_read_miss=generic_read_without_iteration_replay".to_string()); + } } ctx.record_lowered_value_with_access_mode_and_facts( "StablePackedArraylikeLoop", @@ -560,12 +575,18 @@ pub(crate) fn try_lower_index_get( .rev() .find(|fact| fact.array_local_id == *array_id && fact.counter_local_id == *counter_id)? .clone(); + // Load the scalar loop index before a nested-derived guard branches. Both + // the direct arm and its exact-source generic fallback consume this value, + // so it must dominate both successors. + let counter_slot = ctx.i32_counter_slots.get(counter_id)?.clone(); + let idx_i32 = ctx.block().load(I32, &counter_slot); + let mut per_read_fallback = None; if fact.revalidate_before_indexed_read { let receiver_slot = ctx.locals.get(array_id)?.clone(); let receiver = ctx.block().load(DOUBLE, &receiver_slot); let live_raw = ctx.block().call( I64, - "js_packed_arraylike_loop_guard_live", + "js_packed_arraylike_loop_revalidate_live", &[ (DOUBLE, &receiver), (DOUBLE, &fact.bound), @@ -582,9 +603,16 @@ pub(crate) fn try_lower_index_get( pass = ctx.block().and(I1, &pass, &length_unchanged); } let continue_idx = ctx.new_block("stable_packed.indexed_read.derived_valid"); + // A later occurrence of `array[counter]` may follow an observable + // getter, proxy trap, or store in the same source iteration. A failed + // revalidation therefore cannot side-exit to the generic loop at the + // current counter: that would replay the earlier effects. Fall back + // for this one indexed read and merge back at the exact source point. + let fallback_idx = ctx.new_block("packed_index.generic_fallback"); + let read_merge_idx = ctx.new_block("packed_index.revalidated_merge"); let continue_label = ctx.block_label(continue_idx); - ctx.block() - .cond_br(&pass, &continue_label, &fact.side_exit_label); + let fallback_label = ctx.block_label(fallback_idx); + ctx.block().cond_br(&pass, &continue_label, &fallback_label); ctx.current_block = continue_idx; let numeric_access = fact .numeric_elements @@ -598,6 +626,7 @@ pub(crate) fn try_lower_index_get( })?; active.live_receiver_handle = Some(live_raw); active.numeric_access = numeric_access; + per_read_fallback = Some((fallback_idx, read_merge_idx, fallback_label, receiver)); } let fact = ctx .stable_packed_loop_facts @@ -606,8 +635,6 @@ pub(crate) fn try_lower_index_get( .find(|fact| fact.array_local_id == *array_id && fact.counter_local_id == *counter_id)? .clone(); let raw = fact.live_receiver_handle?; - let counter_slot = ctx.i32_counter_slots.get(counter_id)?.clone(); - let idx_i32 = ctx.block().load(I32, &counter_slot); let idx_i64 = ctx.block().zext(I32, &idx_i32, I64); if let Some(access) = fact.numeric_access { let byte_offset = ctx.block().shl(I64, &idx_i64, "3"); @@ -628,7 +655,13 @@ pub(crate) fn try_lower_index_get( .block() .select(I1, &access.is_plain, I64, &plain_addr, &object_addr); let element_ptr = ctx.block().inttoptr(I64, &element_addr); - return Some(ctx.block().load(DOUBLE, &element_ptr)); + let direct = ctx.block().load(DOUBLE, &element_ptr); + return Some(finish_revalidated_read( + ctx, + direct, + idx_i32, + per_read_fallback, + )); } let kind = descriptor_word(ctx, &fact.descriptor, 0); @@ -705,9 +738,13 @@ pub(crate) fn try_lower_index_get( } else { meta_native }; + let read_miss_label = per_read_fallback + .as_ref() + .map(|(_, _, label, _)| label.as_str()) + .unwrap_or(fact.side_exit_label.as_str()); let has_meta = ctx.block().icmp_ne(I64, &meta, "0"); ctx.block() - .cond_br(&has_meta, &object_spill_ptr_label, &fact.side_exit_label); + .cond_br(&has_meta, &object_spill_ptr_label, read_miss_label); ctx.current_block = object_spill_ptr_idx; let meta_ptr = ctx.block().inttoptr(I64, &meta); @@ -717,7 +754,7 @@ pub(crate) fn try_lower_index_get( let spill_deref_idx = ctx.new_block("stable_packed.load.object.spill_deref"); let spill_deref_label = ctx.block_label(spill_deref_idx); ctx.block() - .cond_br(&has_spill, &spill_deref_label, &fact.side_exit_label); + .cond_br(&has_spill, &spill_deref_label, read_miss_label); ctx.current_block = spill_deref_idx; let spill_ptr = ctx.block().inttoptr(I64, &spill); @@ -727,7 +764,7 @@ pub(crate) fn try_lower_index_get( let spill_load_idx = ctx.new_block("stable_packed.load.object.spill_load"); let spill_load_label = ctx.block_label(spill_load_idx); ctx.block() - .cond_br(&in_bounds, &spill_load_label, &fact.side_exit_label); + .cond_br(&in_bounds, &spill_load_label, read_miss_label); ctx.current_block = spill_load_idx; let spill_word = ctx.block().add(I64, &slot, "1"); @@ -739,16 +776,56 @@ pub(crate) fn try_lower_index_get( ctx.block().br(&merge_label); ctx.current_block = merge_idx; - Some(ctx.block().phi( + let direct = ctx.block().phi( DOUBLE, &[ (&plain_value, &plain_end), (&inline_value, &inline_end), (&spill_value, &spill_end), ], + ); + Some(finish_revalidated_read( + ctx, + direct, + idx_i32, + per_read_fallback, )) } +type PerReadFallback = (usize, usize, String, String); + +/// Complete a nested-derived indexed read. The direct arm has already +/// consumed the live raw address with no intervening safepoint. On a guard or +/// defensive-layout miss, the ordinary arraylike helper performs exactly this +/// source read and rejoins without replaying any preceding statement effects. +fn finish_revalidated_read( + ctx: &mut FnCtx<'_>, + direct: String, + idx_i32: String, + fallback: Option, +) -> String { + let Some((fallback_idx, merge_idx, _, receiver)) = fallback else { + return direct; + }; + let direct_end = ctx.block().label.clone(); + let merge_label = ctx.block_label(merge_idx); + ctx.block().br(&merge_label); + + ctx.current_block = fallback_idx; + let index = ctx.block().sitofp(I32, &idx_i32, DOUBLE); + let generic = ctx.block().call( + DOUBLE, + "js_packed_arraylike_index_get", + &[(DOUBLE, &receiver), (DOUBLE, &index), (PTR, "null")], + ); + let fallback_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = merge_idx; + ctx.block() + .phi(DOUBLE, &[(&direct, &direct_end), (&generic, &fallback_end)]) +} + pub(crate) fn has_numeric_index_fact(ctx: &FnCtx<'_>, expr: &Expr) -> bool { let Expr::IndexGet { object, index } = expr else { return false; @@ -783,7 +860,7 @@ pub(super) fn emit_iteration_guard( let receiver = crate::expr::lower_expr(ctx, &Expr::LocalGet(fact.array_local_id))?; let live_raw = ctx.block().call( I64, - "js_packed_arraylike_loop_guard_live", + "js_packed_arraylike_loop_revalidate_live", &[ (DOUBLE, &receiver), (DOUBLE, &fact.bound), diff --git a/crates/perry-codegen/src/temp_root_coverage/mod.rs b/crates/perry-codegen/src/temp_root_coverage/mod.rs index 6248c8155b..6a090a703a 100644 --- a/crates/perry-codegen/src/temp_root_coverage/mod.rs +++ b/crates/perry-codegen/src/temp_root_coverage/mod.rs @@ -68,6 +68,8 @@ fn entry_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/type_analysis/numeric/tests.rs b/crates/perry-codegen/src/type_analysis/numeric/tests.rs index 86b697d489..b080f8af97 100644 --- a/crates/perry-codegen/src/type_analysis/numeric/tests.rs +++ b/crates/perry-codegen/src/type_analysis/numeric/tests.rs @@ -32,6 +32,8 @@ fn ir_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/app_window_config_options.rs b/crates/perry-codegen/tests/app_window_config_options.rs index 5013db904d..ca6f83136c 100644 --- a/crates/perry-codegen/tests/app_window_config_options.rs +++ b/crates/perry-codegen/tests/app_window_config_options.rs @@ -32,6 +32,8 @@ fn empty_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/argless_builtin_extra_args.rs b/crates/perry-codegen/tests/argless_builtin_extra_args.rs index e4a09ad275..14214adf35 100644 --- a/crates/perry-codegen/tests/argless_builtin_extra_args.rs +++ b/crates/perry-codegen/tests/argless_builtin_extra_args.rs @@ -29,6 +29,8 @@ fn empty_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/class_field_store_pointer_test.rs b/crates/perry-codegen/tests/class_field_store_pointer_test.rs index a9e60f9e4f..12ab0206c9 100644 --- a/crates/perry-codegen/tests/class_field_store_pointer_test.rs +++ b/crates/perry-codegen/tests/class_field_store_pointer_test.rs @@ -38,6 +38,8 @@ fn empty_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/class_keys_gc_root.rs b/crates/perry-codegen/tests/class_keys_gc_root.rs index cdd1cb3782..8fc7b15ef5 100644 --- a/crates/perry-codegen/tests/class_keys_gc_root.rs +++ b/crates/perry-codegen/tests/class_keys_gc_root.rs @@ -47,6 +47,8 @@ fn entry_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/constructor_recursion.rs b/crates/perry-codegen/tests/constructor_recursion.rs index e8aa5274a7..0ba92ea626 100644 --- a/crates/perry-codegen/tests/constructor_recursion.rs +++ b/crates/perry-codegen/tests/constructor_recursion.rs @@ -22,6 +22,8 @@ fn empty_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/destructure_call_location.rs b/crates/perry-codegen/tests/destructure_call_location.rs index f1481491f9..5d72103970 100644 --- a/crates/perry-codegen/tests/destructure_call_location.rs +++ b/crates/perry-codegen/tests/destructure_call_location.rs @@ -39,6 +39,8 @@ fn base_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/i64_spec_ternary_recursion.rs b/crates/perry-codegen/tests/i64_spec_ternary_recursion.rs index 8e1d2bcfe0..34efdcf750 100644 --- a/crates/perry-codegen/tests/i64_spec_ternary_recursion.rs +++ b/crates/perry-codegen/tests/i64_spec_ternary_recursion.rs @@ -39,6 +39,8 @@ fn empty_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/ios_platform_api_lowering.rs b/crates/perry-codegen/tests/ios_platform_api_lowering.rs index 1ec033d175..e6772ab3c3 100644 --- a/crates/perry-codegen/tests/ios_platform_api_lowering.rs +++ b/crates/perry-codegen/tests/ios_platform_api_lowering.rs @@ -24,6 +24,8 @@ fn options(target: Option<&str>) -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: Default::default(), type_aliases: Default::default(), diff --git a/crates/perry-codegen/tests/large_object_barriers.rs b/crates/perry-codegen/tests/large_object_barriers.rs index 0d345cc417..c38b1a07a7 100644 --- a/crates/perry-codegen/tests/large_object_barriers.rs +++ b/crates/perry-codegen/tests/large_object_barriers.rs @@ -22,6 +22,8 @@ fn empty_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/loop_safepoint_purity.rs b/crates/perry-codegen/tests/loop_safepoint_purity.rs index 5ad1983845..38c4ea9f8b 100644 --- a/crates/perry-codegen/tests/loop_safepoint_purity.rs +++ b/crates/perry-codegen/tests/loop_safepoint_purity.rs @@ -60,6 +60,8 @@ fn entry_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs b/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs index 807a6a4de9..091b051afc 100644 --- a/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs +++ b/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs @@ -28,6 +28,8 @@ fn entry_opts(target: Option<&str>) -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/native_proof_buffer_views.rs b/crates/perry-codegen/tests/native_proof_buffer_views.rs index e030c3ef6d..2fd2d74acf 100644 --- a/crates/perry-codegen/tests/native_proof_buffer_views.rs +++ b/crates/perry-codegen/tests/native_proof_buffer_views.rs @@ -53,6 +53,8 @@ fn empty_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index 140c8d2266..3d2477b0ad 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -44,6 +44,8 @@ fn empty_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/node_test_mock_property_presence.rs b/crates/perry-codegen/tests/node_test_mock_property_presence.rs index fd935ffab1..27cb2bcc67 100644 --- a/crates/perry-codegen/tests/node_test_mock_property_presence.rs +++ b/crates/perry-codegen/tests/node_test_mock_property_presence.rs @@ -25,6 +25,8 @@ fn ir_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/perry_builtin_name_collision.rs b/crates/perry-codegen/tests/perry_builtin_name_collision.rs index a195f6ec46..19790caf83 100644 --- a/crates/perry-codegen/tests/perry_builtin_name_collision.rs +++ b/crates/perry-codegen/tests/perry_builtin_name_collision.rs @@ -40,6 +40,8 @@ fn base_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/release_boxes_lowering.rs b/crates/perry-codegen/tests/release_boxes_lowering.rs index 0371bd9fb4..95090c74f5 100644 --- a/crates/perry-codegen/tests/release_boxes_lowering.rs +++ b/crates/perry-codegen/tests/release_boxes_lowering.rs @@ -39,6 +39,8 @@ fn entry_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/scalar_replaced_slot_roots.rs b/crates/perry-codegen/tests/scalar_replaced_slot_roots.rs index 3c280825bc..7320971b9b 100644 --- a/crates/perry-codegen/tests/scalar_replaced_slot_roots.rs +++ b/crates/perry-codegen/tests/scalar_replaced_slot_roots.rs @@ -79,6 +79,8 @@ fn entry_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/shadow_slot_hygiene.rs b/crates/perry-codegen/tests/shadow_slot_hygiene.rs index e2be3180b1..9732b04d43 100644 --- a/crates/perry-codegen/tests/shadow_slot_hygiene.rs +++ b/crates/perry-codegen/tests/shadow_slot_hygiene.rs @@ -52,6 +52,8 @@ fn empty_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/static_symbol_hygiene.rs b/crates/perry-codegen/tests/static_symbol_hygiene.rs index c4cf48fb46..85c0ceb92d 100644 --- a/crates/perry-codegen/tests/static_symbol_hygiene.rs +++ b/crates/perry-codegen/tests/static_symbol_hygiene.rs @@ -22,6 +22,8 @@ fn empty_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/temp_root_operand_temporaries.rs b/crates/perry-codegen/tests/temp_root_operand_temporaries.rs index 291d0c4626..f8ad2febe6 100644 --- a/crates/perry-codegen/tests/temp_root_operand_temporaries.rs +++ b/crates/perry-codegen/tests/temp_root_operand_temporaries.rs @@ -79,6 +79,8 @@ fn entry_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/typed_feedback.rs b/crates/perry-codegen/tests/typed_feedback.rs index 6d3cf4d3a4..fadd9e71c7 100644 --- a/crates/perry-codegen/tests/typed_feedback.rs +++ b/crates/perry-codegen/tests/typed_feedback.rs @@ -103,6 +103,8 @@ fn empty_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/typed_shape_declared_at_allocation.rs b/crates/perry-codegen/tests/typed_shape_declared_at_allocation.rs index 21c015876c..4961f50b2b 100644 --- a/crates/perry-codegen/tests/typed_shape_declared_at_allocation.rs +++ b/crates/perry-codegen/tests/typed_shape_declared_at_allocation.rs @@ -47,6 +47,8 @@ fn empty_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/typed_shape_descriptor.rs b/crates/perry-codegen/tests/typed_shape_descriptor.rs index a6edb548e5..254ad21de2 100644 --- a/crates/perry-codegen/tests/typed_shape_descriptor.rs +++ b/crates/perry-codegen/tests/typed_shape_descriptor.rs @@ -22,6 +22,8 @@ fn empty_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/typed_shape_descriptors.rs b/crates/perry-codegen/tests/typed_shape_descriptors.rs index dec0f90992..505941615d 100644 --- a/crates/perry-codegen/tests/typed_shape_descriptors.rs +++ b/crates/perry-codegen/tests/typed_shape_descriptors.rs @@ -25,6 +25,8 @@ fn empty_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-hir/src/lower/expr_call/module_static.rs b/crates/perry-hir/src/lower/expr_call/module_static.rs index 4b33a91fd1..0606625b65 100644 --- a/crates/perry-hir/src/lower/expr_call/module_static.rs +++ b/crates/perry-hir/src/lower/expr_call/module_static.rs @@ -699,18 +699,29 @@ pub(super) fn try_module_static_methods( return Ok(Ok(Expr::MathPow(Box::new(base), Box::new(exp)))); } "min" => { - if has_spread && args.len() == 1 { - return Ok(Ok(Expr::MathMinSpread(Box::new( - args.into_iter().next().unwrap(), - )))); + if has_spread { + if args.len() == 1 { + return Ok(Ok(Expr::MathMinSpread(Box::new( + args.into_iter().next().unwrap(), + )))); + } + // The compact HIR form records one spread + // operand and no fixed prefix. Keep mixed + // calls on the generic CallSpread path; folding + // `Math.min(fixed, ...tail)` into MathMin would + // coerce `tail` as one scalar argument. + return Ok(Err(args)); } return Ok(Ok(Expr::MathMin(args))); } "max" => { - if has_spread && args.len() == 1 { - return Ok(Ok(Expr::MathMaxSpread(Box::new( - args.into_iter().next().unwrap(), - )))); + if has_spread { + if args.len() == 1 { + return Ok(Ok(Expr::MathMaxSpread(Box::new( + args.into_iter().next().unwrap(), + )))); + } + return Ok(Err(args)); } return Ok(Ok(Expr::MathMax(args))); } diff --git a/crates/perry-hir/src/lower/expr_call/native_module_spread_tests.rs b/crates/perry-hir/src/lower/expr_call/native_module_spread_tests.rs index 70ea4a9e3f..57c8a9daa3 100644 --- a/crates/perry-hir/src/lower/expr_call/native_module_spread_tests.rs +++ b/crates/perry-hir/src/lower/expr_call/native_module_spread_tests.rs @@ -204,3 +204,22 @@ fn non_module_spread_intrinsics_are_untouched() { "Math.min spread lost its fast path: {h}" ); } + +#[test] +fn mixed_fixed_and_spread_math_calls_keep_the_spread_marker() { + for src in [ + "const xs = [3, 1, 2]; console.log(Math.min(-1, ...xs));", + "const xs = [3, 1, 2]; console.log(Math.max(-1, ...xs));", + "const xs = [3, 1, 2]; console.log(Math['max'](-1, ...xs));", + ] { + let h = hir(src); + assert!( + h.contains("CallSpread"), + "a mixed Math call must retain its fixed/spread argument boundary: {h}" + ); + assert!( + !h.contains("MathMin([") && !h.contains("MathMax(["), + "the spread tail must not be coerced as one scalar Math argument: {h}" + ); + } +} diff --git a/crates/perry-runtime/src/array/flat_clone.rs b/crates/perry-runtime/src/array/flat_clone.rs index f0def80c51..3fd492ecbb 100644 --- a/crates/perry-runtime/src/array/flat_clone.rs +++ b/crates/perry-runtime/src/array/flat_clone.rs @@ -120,6 +120,18 @@ pub(crate) fn dense_spread_source(value: f64) -> Option<*const ArrayHeader> { /// it, then uses the copied values only when the returned arity is nonnegative. #[no_mangle] pub unsafe extern "C" fn js_short_packed_spread_values(value: f64, out: *mut f64) -> i32 { + // Call/new spread lowering has historically routed nullish sources through + // `js_array_like_to_array`, where they contribute no arguments. Preserve + // that established Perry behaviour in the guarded path as well: otherwise + // taking the optimization would turn an accepted call into a TypeError in + // the fallback materializer. This also matches old TypeScript's emitted + // `[fixed].concat(optionalArgs)` shape used by perform-ecs@0.7.8. + if matches!( + value.to_bits(), + crate::value::TAG_UNDEFINED | crate::value::TAG_NULL + ) { + return 0; + } let Some(arr) = dense_spread_source(value) else { return -1; }; diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index bbd80beca2..488bf0c444 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -146,8 +146,8 @@ pub use self::iter_object::{ array_values_iter_null_done, dispatch_array_iterator_method, js_array_entries_iter_obj, js_array_keys_iter_obj, js_array_values_iter_obj, ARRAY_ITERATOR_CLASS_ID, }; -pub(crate) use self::iterator::is_builtin_iterator_class_id; pub(crate) use self::iterator::iter_bt_dump; +pub(crate) use self::iterator::{array_from_spread_value, is_builtin_iterator_class_id}; pub use self::iterator::{ js_array_spread_append, js_for_of_to_array, js_get_async_iterator, js_iterator_to_array, }; diff --git a/crates/perry-runtime/src/array/spread_dense_tests.rs b/crates/perry-runtime/src/array/spread_dense_tests.rs index 235c1682cb..456913440d 100644 --- a/crates/perry-runtime/src/array/spread_dense_tests.rs +++ b/crates/perry-runtime/src/array/spread_dense_tests.rs @@ -10,7 +10,7 @@ //! protocol must still run. use super::*; -use crate::value::{JSValue, TAG_HOLE, TAG_UNDEFINED}; +use crate::value::{JSValue, TAG_HOLE, TAG_NULL, TAG_UNDEFINED}; fn boxed(arr: *mut ArrayHeader) -> f64 { crate::value::js_nanbox_pointer(arr as i64) @@ -252,6 +252,19 @@ fn short_packed_call_guard_copies_empty_and_one_element_arrays() { assert_eq!(out[0], 37.0); } +#[test] +fn short_packed_call_guard_preserves_nullish_call_spread_as_empty() { + let mut out = [f64::NAN; 4]; + assert_eq!( + unsafe { js_short_packed_spread_values(f64::from_bits(TAG_UNDEFINED), out.as_mut_ptr()) }, + 0 + ); + assert_eq!( + unsafe { js_short_packed_spread_values(f64::from_bits(TAG_NULL), out.as_mut_ptr()) }, + 0 + ); +} + #[test] fn short_packed_call_guard_rejects_holes_and_oversized_arrays() { let mut out = [0.0; 4]; diff --git a/crates/perry-runtime/src/array/subclass.rs b/crates/perry-runtime/src/array/subclass.rs index 0b20ff6ba7..9ec0ef3ee5 100644 --- a/crates/perry-runtime/src/array/subclass.rs +++ b/crates/perry-runtime/src/array/subclass.rs @@ -680,7 +680,11 @@ fn packed_arraylike_loop_guard( let Some((object, layout)) = dense_layout_for_value(live_receiver) else { return None; }; - if !crate::object::object_spill_enabled() || layout.length_slot >= layout.live_inline_slots { + // Stable-loop codegen already handles both inline and object-owned spill + // element slots. `layout_length_value` below has the same split for the + // semantic length slot, so classes with enough declared fields to spill + // `length` (the real wolf-ecs Query/Archetype shape) are equally safe. + if !crate::object::object_spill_enabled() { return None; } let Some(length) = nonnegative_u32_length(layout_length_value(object, layout)) else { @@ -728,6 +732,7 @@ pub extern "C" fn js_packed_arraylike_loop_guard( /// consumes it before the next safepoint and reloads/revalidates on the next /// iteration; the returned address is never stored as a GC root. #[no_mangle] +#[inline(never)] pub extern "C" fn js_packed_arraylike_loop_guard_live( receiver: f64, bound: f64, @@ -739,6 +744,220 @@ pub extern "C" fn js_packed_arraylike_loop_guard_live( .unwrap_or(0) } +/// Revalidate a receiver against facts published by a successful complete +/// loop admission. Unlike the admitting guard, this path never rediscovers or +/// republishes the dense layout: exact class/ShapeId and header checks make the +/// seven scalar words an O(1) semantic version token. It is used between +/// observable indexed effects in a nested fast loop. +/// +/// The receiver is live and rooted at the generated call site. One retained +/// Array-growth forwarding edge is accepted, while every brand, shape, +/// prototype, descriptor, length, packedness, or numeric-proof mismatch +/// returns zero to the exact-source generic read. +#[no_mangle] +pub extern "C" fn js_packed_arraylike_loop_revalidate_live( + receiver: f64, + bound: f64, + require_numeric: i32, + facts: *const u64, +) -> i64 { + if facts.is_null() { + return 0; + } + // The public Wolf hot path is an admitted Array-subclass. Its descriptor + // is already the authority for the exact class/ShapeId and dense layout; + // do not repeat generic pointer classification, forwarding triage, cache + // lookup, or bound parsing before checking those words. A genuine JS heap + // pointer is the only value that can pass `is_pointer`, so its prepended + // GcHeader is safe to inspect directly. Plain Arrays retain the complete + // defensive path below because their growth forwarding stubs are valid. + if unsafe { facts.read() } == 2 { + return revalidate_admitted_subclass_live(receiver, bound, require_numeric, facts); + } + let live_length_bound = bound == -1.0; + let js = JSValue::from_bits(receiver.to_bits()); + if !js.is_pointer() { + return 0; + } + let source = js.as_pointer::(); + let Some(source_header) = + (unsafe { crate::value::addr_class::try_read_gc_header(source as usize) }) + else { + return 0; + }; + let raw = if source_header.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 { + if source_header.obj_type != crate::gc::GC_TYPE_ARRAY { + return 0; + } + let target = unsafe { crate::gc::forwarding_address(source_header) }; + let Some(target_header) = + (unsafe { crate::value::addr_class::try_read_gc_header(target as usize) }) + else { + return 0; + }; + if target_header.obj_type != crate::gc::GC_TYPE_ARRAY + || target_header.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 + { + return 0; + } + target + } else { + source + }; + let Some(header) = (unsafe { crate::value::addr_class::try_read_gc_header(raw as usize) }) + else { + return 0; + }; + let (kind, receiver_word, length_slot, element_base, packed_bounds, admitted_bound) = unsafe { + ( + facts.add(0).read(), + facts.add(2).read(), + facts.add(3).read() as u32, + facts.add(4).read() as u32, + facts.add(5).read(), + facts.add(6).read() as u32, + ) + }; + if admitted_bound > 16_000_000 { + return 0; + } + if !live_length_bound + && (!bound.is_finite() + || bound < 0.0 + || bound.fract() != 0.0 + || bound != f64::from(admitted_bound)) + { + return 0; + } + + if kind == 1 { + if header.obj_type != crate::gc::GC_TYPE_ARRAY + || header._reserved & crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS != 0 + || super::PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED.load(Ordering::Relaxed) != 0 + { + return 0; + } + let array = raw.cast::(); + let (length, capacity) = unsafe { ((*array).length, (*array).capacity) }; + if (live_length_bound && length != admitted_bound) + || (!live_length_bound && length < admitted_bound) + || length > capacity + || capacity > 16_000_000 + || (require_numeric != 0 && header._reserved & crate::gc::GC_ARRAY_RAW_F64_LAYOUT == 0) + { + return 0; + } + return raw as i64; + } + + if kind != 2 + || header.obj_type != crate::gc::GC_TYPE_OBJECT + || header.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 + { + return 0; + } + let object = raw.cast::(); + let current_receiver_word = unsafe { ptr::read_unaligned(raw.cast::()) }; + if current_receiver_word != receiver_word + || crate::object::prototype_chain::object_has_prototype_override(raw as usize) + { + return 0; + } + let dense_prefix_len = packed_bounds as u32; + let live_inline_slots = (packed_bounds >> 32) as u32; + if admitted_bound > dense_prefix_len { + return 0; + } + let layout = DenseSubclassLayout { + length_slot, + element_base, + dense_prefix_len, + live_inline_slots, + }; + let Some(length) = nonnegative_u32_length(layout_length_value(object, layout)) else { + return 0; + }; + if (live_length_bound && length != admitted_bound) + || (!live_length_bound && length < admitted_bound) + || (require_numeric != 0 + && !unsafe { + subclass_numeric_prefix_is_proven(object, (*object).parent_class_id, admitted_bound) + }) + { + return 0; + } + raw as i64 +} + +#[inline(always)] +fn revalidate_admitted_subclass_live( + receiver: f64, + bound: f64, + require_numeric: i32, + facts: *const u64, +) -> i64 { + let js = JSValue::from_bits(receiver.to_bits()); + if !js.is_pointer() { + return 0; + } + let raw = js.as_pointer::(); + let header = unsafe { + &*raw + .sub(crate::gc::GC_HEADER_SIZE) + .cast::() + }; + if header.obj_type != crate::gc::GC_TYPE_OBJECT + || header.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 + { + return 0; + } + let object = raw.cast::(); + let (receiver_word, length_slot, element_base, packed_bounds, admitted_bound) = unsafe { + ( + facts.add(2).read(), + facts.add(3).read() as u32, + facts.add(4).read() as u32, + facts.add(5).read(), + facts.add(6).read() as u32, + ) + }; + if admitted_bound > 16_000_000 + || unsafe { ptr::read_unaligned(raw.cast::()) } != receiver_word + { + return 0; + } + let meta = unsafe { (*object).meta }; + if !meta.is_null() + && unsafe { (*meta).flags } & crate::object::OBJECT_META_FLAG_PROTO_OVERRIDE != 0 + { + return 0; + } + let dense_prefix_len = packed_bounds as u32; + let live_inline_slots = (packed_bounds >> 32) as u32; + if admitted_bound > dense_prefix_len { + return 0; + } + let layout = DenseSubclassLayout { + length_slot, + element_base, + dense_prefix_len, + live_inline_slots, + }; + let Some(length) = nonnegative_u32_length(layout_length_value(object, layout)) else { + return 0; + }; + if (bound == -1.0 && length != admitted_bound) + || (bound != -1.0 && length < admitted_bound) + || (require_numeric != 0 + && !unsafe { + subclass_numeric_prefix_is_proven(object, (*object).parent_class_id, admitted_bound) + }) + { + return 0; + } + raw as i64 +} + #[cfg(feature = "keepalive-anchors")] #[used] static KEEP_JS_PACKED_ARRAYLIKE_LOOP_GUARD: extern "C" fn(f64, f64, i32, *mut u64) -> i32 = @@ -749,6 +968,15 @@ static KEEP_JS_PACKED_ARRAYLIKE_LOOP_GUARD: extern "C" fn(f64, f64, i32, *mut u6 static KEEP_JS_PACKED_ARRAYLIKE_LOOP_GUARD_LIVE: extern "C" fn(f64, f64, i32, *mut u64) -> i64 = js_packed_arraylike_loop_guard_live; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_PACKED_ARRAYLIKE_LOOP_REVALIDATE_LIVE: extern "C" fn( + f64, + f64, + i32, + *const u64, +) -> i64 = js_packed_arraylike_loop_revalidate_live; + #[cfg(feature = "keepalive-anchors")] #[used] static KEEP_JS_PACKED_ARRAYLIKE_INDEX_GET: extern "C" fn(f64, f64, *mut u64) -> f64 = diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 37bf382edd..57e721c2af 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -539,8 +539,8 @@ pub unsafe extern "C-unwind" fn js_native_call_method_apply_by_id( /// Materialize `fixed..., ...spread` for the generic branch of a short packed /// spread callsite. The fast branch has already evaluated all operands; doing /// the fallback assembly here preserves that source order without re-running -/// an expression, and [`js_array_clone_for_spread`] preserves every observable -/// iterator case (own/prototype overrides, proxies, accessors, and throws). +/// an expression, and [`crate::object::js_array_like_to_array`] preserves the +/// the full iterator protocol for every proof miss. /// /// The returned array is consumed immediately by /// [`js_native_call_method_apply_by_id`]. Every input and both arrays are held @@ -562,7 +562,19 @@ pub unsafe extern "C-unwind" fn js_spread_tail_fallback_args( let spread_handle = scope.root_nanbox_f64(spread); let (_, rooted_spread) = spread_handle.across_nanbox(|| ()); - let spread_array = crate::array::js_array_clone_for_spread(rooted_spread); + // Drive the real iterator protocol on a guard miss. The older + // `js_array_like_to_array` shortcut reinterprets an Array Proxy handle or + // object-backed Array-subclass instance as an `ArrayHeader`, making both + // appear empty. Nullish tails retain Perry's established optional-tail + // extension and contribute zero arguments, matching the admitted arm. + let spread_array = if matches!( + rooted_spread.to_bits(), + crate::value::TAG_UNDEFINED | crate::value::TAG_NULL + ) { + crate::array::js_array_alloc(0) + } else { + crate::array::array_from_spread_value(rooted_spread) + }; let spread_array_handle = scope.root_raw_mut_ptr(spread_array); let spread_len = spread_array_handle.with_const_ptr(|arr: *const crate::array::ArrayHeader| { if arr.is_null() { diff --git a/crates/perry-runtime/src/typed_feedback.rs b/crates/perry-runtime/src/typed_feedback.rs index 43bfafe724..5dffc05ee9 100644 --- a/crates/perry-runtime/src/typed_feedback.rs +++ b/crates/perry-runtime/src/typed_feedback.rs @@ -1050,6 +1050,7 @@ pub extern "C" fn js_typed_feedback_object_set_field_by_name_fast( #[path = "typed_feedback/guards.rs"] mod guards; pub use guards::{ + js_closure_exact_func_guard, js_object_own_method_cache_miss, js_typed_feedback_class_field_get_guard, js_typed_feedback_class_field_set_guard, js_typed_feedback_closure_direct_call_guard, js_typed_feedback_method_direct_call_guard, js_typed_feedback_native_call_method, js_typed_feedback_native_call_method_apply, diff --git a/crates/perry-runtime/src/typed_feedback/guards.rs b/crates/perry-runtime/src/typed_feedback/guards.rs index 3461e73118..4d8a619944 100644 --- a/crates/perry-runtime/src/typed_feedback/guards.rs +++ b/crates/perry-runtime/src/typed_feedback/guards.rs @@ -1165,6 +1165,134 @@ pub extern "C" fn js_typed_feedback_closure_direct_call_guard( } } +/// Validate only the live closure function identity for a statically proven +/// direct call. +/// +/// Whole-program object-literal capabilities already prove the target's +/// arity/rest contract at compile time. Repeating the closure registry lookups +/// and recording a typed-feedback observation on every call therefore adds no +/// safety. This smaller guard deliberately keeps the speculation-safe closure +/// header validation used by the universal dispatcher: arbitrary replacement +/// values, small handle-band ids, and bound-function sentinels must miss the +/// direct arm without ever being dereferenced or called as code. +#[no_mangle] +pub extern "C" fn js_closure_exact_func_guard( + closure_value: f64, + expected_func_ptr: *const u8, +) -> i32 { + if expected_func_ptr.is_null() { + return 0; + } + let bits = closure_value.to_bits(); + let raw_ptr = if (bits & TAG_MASK) == POINTER_TAG { + (bits & POINTER_MASK) as *const crate::closure::ClosureHeader + } else if (bits >> 48) == 0 && bits >= 0x10000 { + bits as *const crate::closure::ClosureHeader + } else { + std::ptr::null() + }; + let closure_ptr = crate::closure::clean_closure_ptr(raw_ptr); + (crate::closure::get_valid_func_ptr(closure_ptr) == expected_func_ptr) as i32 +} + +/// Revalidate and prime the shape token for an own object-literal method. +/// +/// The exported adapter object may append ordinary state fields during +/// `setup()` after its module-initial ShapeId was published. Exact initial- +/// shape guards therefore miss permanently even though the method's key, +/// slot, descriptor, and closure are unchanged. This cold IC-miss helper +/// accepts such append-only successors by re-proving the method key at its +/// original slot and the live closure identity, then publishes the live packed +/// `(class_id, ShapeId)` token for an inline hot-path comparison. +/// +/// Deletion/compaction changes the key at `field_index`; replacement changes +/// the closure; descriptor/prototype mutation either sets the descriptor bit +/// or mints a semantic successor. A spill-only metadata record is allowed: +/// appending past the object's inline birth width creates one even though the +/// original method slot and its lookup semantics remain unchanged. +#[no_mangle] +pub unsafe extern "C" fn js_object_own_method_cache_miss( + receiver: f64, + expected_class_id: u32, + field_index: u32, + method_name_ptr: *const i8, + method_name_len: usize, + expected_func_ptr: *const u8, + cache_token: *mut u64, +) -> u64 { + if !cache_token.is_null() { + *cache_token = 0; + } + if expected_class_id == 0 || expected_func_ptr.is_null() || cache_token.is_null() { + return 0; + } + let Some(method_bytes) = method_name_bytes(method_name_ptr, method_name_len) else { + return 0; + }; + let object_addr = normalize_raw_object_addr(receiver.to_bits()); + let Some(gc_header) = gc_header_for_user_addr(object_addr) else { + return 0; + }; + if (*gc_header).obj_type != crate::gc::GC_TYPE_OBJECT + || (*gc_header).gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 + || (*gc_header)._reserved + & (crate::gc::OBJ_FLAG_HAS_DESCRIPTORS | crate::gc::OBJ_FLAG_PACKED_NUMERIC_PROOF) + != 0 + { + return 0; + } + let object = object_addr as *const ObjectHeader; + if !crate::object::object_is_regular(object) || (*object).class_id != expected_class_id { + return 0; + } + let meta = (*object).meta; + if !meta.is_null() + && ((*meta).prototype != 0 + || (*meta).attr_key_bits != 0 + || (*meta).accessor_key_bits != 0 + || (*meta).flags != 0 + || (*meta).private_evaluation_brand != 0) + { + return 0; + } + let Some(shape) = crate::object::shapes::object_shape_descriptor(object) else { + return 0; + }; + if field_index >= shape.logical_key_count || field_index >= shape.live_inline_slot_count { + return 0; + } + let keys = shape.keys as usize as *const ArrayHeader; + if keys.is_null() + || !crate::string::js_string_key_matches_bytes( + crate::array::js_array_get(keys, field_index), + method_bytes, + ) + { + return 0; + } + + let fields = (object as *const u8).add(std::mem::size_of::()) as *const u64; + let closure_bits = std::ptr::read(fields.add(field_index as usize)); + let raw_ptr = if (closure_bits & TAG_MASK) == POINTER_TAG { + (closure_bits & POINTER_MASK) as *const crate::closure::ClosureHeader + } else if (closure_bits >> 48) == 0 && closure_bits >= 0x10000 { + closure_bits as *const crate::closure::ClosureHeader + } else { + std::ptr::null() + }; + let closure = crate::closure::clean_closure_ptr(raw_ptr); + if crate::closure::get_valid_func_ptr(closure) != expected_func_ptr { + return 0; + } + + let shape_id = crate::object::shapes::object_shape_id(object); + if !crate::object::shapes::is_shape_id(shape_id) { + return 0; + } + *cache_token = ((shape_id as u64) << 32) | expected_class_id as u64; + closure as u64 +} + // #1764 (follow-up): the guard helpers in this submodule are codegen-emitted // `#[no_mangle]` exports with no Rust-side caller, so the auto-optimize // whole-program thin-LTO + `strip=true` build internalizes + dead-strips them @@ -1195,6 +1323,10 @@ mod keep_guard_symbols { #[cfg(feature = "keepalive-anchors")] #[used] static G3: extern "C" fn(u64, f64, *const u8, u32, u32) -> i32 = js_typed_feedback_closure_direct_call_guard; #[cfg(feature = "keepalive-anchors")] + #[used] static G3B: extern "C" fn(f64, *const u8) -> i32 = js_closure_exact_func_guard; + #[cfg(feature = "keepalive-anchors")] + #[used] static G3C: unsafe extern "C" fn(f64, u32, u32, *const i8, usize, *const u8, *mut u64) -> u64 = js_object_own_method_cache_miss; + #[cfg(feature = "keepalive-anchors")] #[used] static G4: unsafe extern "C" fn(f64, u32, u32, u32) -> i32 = js_method_direct_shape_guard; #[cfg(feature = "keepalive-anchors")] #[used] static G4B: unsafe extern "C" fn(f64, *mut u32, u32) -> u32 = js_method_direct_shape_class; diff --git a/crates/perry-runtime/src/typed_feedback/tests.rs b/crates/perry-runtime/src/typed_feedback/tests.rs index 5238ef437e..4eb7f12bc4 100644 --- a/crates/perry-runtime/src/typed_feedback/tests.rs +++ b/crates/perry-runtime/src/typed_feedback/tests.rs @@ -1274,6 +1274,18 @@ fn representation_lowering_helpers_have_lto_keepalive_anchors() { "static G3: extern \"C\" fn(u64, f64, *const u8, u32, u32) -> i32", "js_typed_feedback_closure_direct_call_guard", ), + ( + guards, + "static G3B", + "static G3B: extern \"C\" fn(f64, *const u8) -> i32", + "js_closure_exact_func_guard", + ), + ( + guards, + "static G3C", + "static G3C: unsafe extern \"C\" fn(f64, u32, u32, *const i8, usize, *const u8, *mut u64) -> u64", + "js_object_own_method_cache_miss", + ), ( guards, "static G4", @@ -2317,6 +2329,134 @@ fn typed_feedback_closure_direct_guard_passes_and_rejects_bound_sentinel() { assert_eq!(site.guard_failures, 1); } +#[test] +fn exact_closure_func_guard_is_safe_and_identity_exact() { + let fn_ptr = test_direct_closure_ptr(); + let closure = crate::closure::js_closure_alloc_singleton(fn_ptr); + let closure_value = crate::value::js_nanbox_pointer(closure as i64); + assert_eq!(js_closure_exact_func_guard(closure_value, fn_ptr), 1); + assert_eq!( + js_closure_exact_func_guard(closure_value, test_direct_method_ptr()), + 0 + ); + + let bound = crate::closure::js_closure_alloc(crate::closure::BOUND_METHOD_FUNC_PTR, 0); + let bound_value = crate::value::js_nanbox_pointer(bound as i64); + assert_eq!(js_closure_exact_func_guard(bound_value, fn_ptr), 0); + assert_eq!(js_closure_exact_func_guard(42.0, fn_ptr), 0); + assert_eq!( + js_closure_exact_func_guard(f64::from_bits(crate::value::POINTER_TAG | 0x10000), fn_ptr), + 0 + ); + assert_eq!( + js_closure_exact_func_guard(closure_value, std::ptr::null()), + 0 + ); +} + +#[test] +fn own_method_cache_accepts_appends_and_rejects_live_method_mutation() { + const CLASS_ID: u32 = 0x7fff_fe75; + let object = crate::object::js_object_alloc(CLASS_ID, 0); + let method_key = crate::string::js_string_from_bytes(b"method".as_ptr(), 6); + let extra_key = crate::string::js_string_from_bytes(b"state".as_ptr(), 5); + let spilled_key = crate::string::js_string_from_bytes(b"spilled".as_ptr(), 7); + let fn_ptr = test_direct_closure_ptr(); + let closure = crate::closure::js_closure_alloc_singleton(fn_ptr); + let closure_value = crate::value::js_nanbox_pointer(closure as i64); + crate::object::js_object_set_field_by_name(object, method_key, closure_value); + let receiver = crate::value::js_nanbox_pointer(object as i64); + let mut cache = 0; + + let first = unsafe { + js_object_own_method_cache_miss( + receiver, + CLASS_ID, + 0, + b"method".as_ptr() as *const i8, + 6, + fn_ptr, + &mut cache, + ) + }; + assert_eq!(first, closure as u64); + assert_ne!(cache, 0); + let initial_shape_token = cache; + + crate::object::js_object_set_field_by_name(object, extra_key, 42.0); + let after_append = unsafe { + js_object_own_method_cache_miss( + receiver, + CLASS_ID, + 0, + b"method".as_ptr() as *const i8, + 6, + fn_ptr, + &mut cache, + ) + }; + assert_eq!(after_append, closure as u64); + assert_ne!( + cache, initial_shape_token, + "append must publish the live successor shape" + ); + + // The third property is beyond this zero-field object's two-slot inline + // floor. It creates ObjectMeta solely to own spill storage; that metadata + // is not a semantic mutation of the original own method. + crate::object::js_object_set_field_by_name(object, spilled_key, 84.0); + assert!(unsafe { !(*object).meta.is_null() }); + let after_spilled_append = unsafe { + js_object_own_method_cache_miss( + receiver, + CLASS_ID, + 0, + b"method".as_ptr() as *const i8, + 6, + fn_ptr, + &mut cache, + ) + }; + assert_eq!(after_spilled_append, closure as u64); + assert_ne!(cache, 0); + + let replacement = crate::closure::js_closure_alloc_singleton(test_direct_method_ptr()); + crate::object::js_object_set_field_by_name( + object, + method_key, + crate::value::js_nanbox_pointer(replacement as i64), + ); + let replaced = unsafe { + js_object_own_method_cache_miss( + receiver, + CLASS_ID, + 0, + b"method".as_ptr() as *const i8, + 6, + fn_ptr, + &mut cache, + ) + }; + assert_eq!(replaced, 0); + assert_eq!(cache, 0); + + crate::object::js_object_set_field_by_name(object, method_key, closure_value); + crate::object::js_object_delete_field(object, method_key); + let deleted = unsafe { + js_object_own_method_cache_miss( + receiver, + CLASS_ID, + 0, + b"method".as_ptr() as *const i8, + 6, + fn_ptr, + &mut cache, + ) + }; + assert_eq!(deleted, 0); + assert_eq!(cache, 0); +} + #[test] fn typed_feedback_trace_json_reports_counts() { let _guard = typed_feedback_test_lock(); diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index aa69e8a842..8fa35a76a0 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -708,8 +708,12 @@ fn compute_object_cache_key_with_env( methods.sort_by(|left, right| left.name.cmp(&right.name)); for method in methods { buf.push_str(&format!( - "{}={}/{}/{};", - method.name, method.func_id, method.param_count, method.field_index + "{}={}/{}/{}/{};", + method.name, + method.func_id, + method.target, + method.param_count, + method.field_index )); } } @@ -718,6 +722,79 @@ fn compute_object_cache_key_with_env( h.field("imported_classes", &buf); } + // #8772: these producer capabilities are harvested from OTHER modules and + // can change the guarded direct-call arms without changing this module's + // own HIR. Keep ordering independent of HashMap/rayon traversal order. + { + let mut candidates: Vec<&perry_codegen::ShortSpreadMethodCandidate> = opts + .short_spread_method_candidates + .values() + .flatten() + .collect(); + candidates.sort_by(|a, b| { + a.method_name + .cmp(&b.method_name) + .then(a.class_id.cmp(&b.class_id)) + .then(a.target.cmp(&b.target)) + }); + let value = candidates + .into_iter() + .map(|candidate| { + format!( + "{}:{}@{}:{}:{}:{}", + candidate.method_name, + candidate.class_id, + candidate.source_prefix, + candidate.target, + candidate.shape_id_global, + candidate.declared_count, + ) + }) + .collect::>() + .join("|"); + h.field("short_spread_method_candidates", &value); + } + + // #8775 reverse-flow object candidates likewise affect modules that do + // not import the producer and therefore are not covered by the imported + // class/object fingerprint above. + { + let mut candidates: Vec<&perry_codegen::ObjectLiteralMethodCandidate> = opts + .object_literal_method_candidates + .values() + .flatten() + .collect(); + candidates.sort_by(|a, b| { + a.method + .name + .cmp(&b.method.name) + .then(a.method.param_count.cmp(&b.method.param_count)) + .then(a.source_prefix.cmp(&b.source_prefix)) + .then(a.source_global_id.cmp(&b.source_global_id)) + .then(a.method.func_id.cmp(&b.method.func_id)) + }); + let value = candidates + .into_iter() + .map(|candidate| { + format!( + "{}/{}:{}@{}#{}:{}:{}:{}:{}:{}", + candidate.method.name, + candidate.method.param_count, + candidate.class_id, + candidate.source_prefix, + candidate.source_global_id, + candidate.source_export_name, + candidate.shape_id_global, + candidate.method.func_id, + candidate.method.target, + candidate.method.field_index, + ) + }) + .collect::>() + .join("|"); + h.field("object_literal_method_candidates", &value); + } + // Imported enums — sort by local name, serialize every member. { let mut v: Vec<&(String, Vec<(String, perry_hir::EnumValue)>)> = 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 518d813d99..7707df8e99 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 @@ -31,6 +31,8 @@ fn empty_opts() -> CompileOptions { namespace_imports: Vec::new(), namespace_member_nested: Vec::new(), imported_classes: Vec::new(), + short_spread_method_candidates: std::sync::Arc::default(), + object_literal_method_candidates: std::sync::Arc::default(), imported_enums: Vec::new(), imported_async_funcs: std::collections::HashSet::new(), type_aliases: std::collections::HashMap::new(), @@ -579,6 +581,7 @@ fn key_changes_with_imported_class_codegen_surface() { methods: vec![perry_codegen::ImportedObjectLiteralMethod { name: "run".into(), func_id: 9, + target: "perry_closure_adapter_js__9".into(), param_count: 1, field_index: 2, }], diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index fbaf832649..a292719472 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -2721,6 +2721,75 @@ pub fn run_with_parse_cache( ) }) .collect(); + // #8772: harvest producer-authored concrete method capabilities across + // the final whole-program HIR before parallel codegen. This is a reverse + // flow as well as an import flow: a generic library can own + // `value.reset(...args)` while an adapter that imports that library owns + // every concrete `reset` implementation. Arc keeps the whole-program map + // shared rather than cloning it once per module job. + let mut short_spread_method_candidates: std::collections::HashMap< + String, + Vec, + > = std::collections::HashMap::new(); + for hir_module in ctx.native_modules.values() { + for candidate in perry_codegen::short_spread_method_capabilities(hir_module) { + short_spread_method_candidates + .entry(candidate.method_name.clone()) + .or_default() + .push(candidate); + } + } + for candidates in short_spread_method_candidates.values_mut() { + candidates.sort_unstable_by(|a, b| { + a.class_id + .cmp(&b.class_id) + .then_with(|| a.target.cmp(&b.target)) + }); + candidates.dedup_by(|a, b| a.class_id == b.class_id && a.target == b.target); + } + let short_spread_method_candidates = std::sync::Arc::new(short_spread_method_candidates); + // #8775: a generic library module can receive an exported adapter object + // through a parameter without importing its defining module. Publish the + // producer's exact immutable object/method facts to every codegen job so a + // dynamic property call can select it with runtime identity + shape + live + // closure guards. This is the object-literal analogue of the reverse-flow + // short-spread registry above. + let mut object_literal_method_candidates: std::collections::HashMap< + String, + Vec, + > = std::collections::HashMap::new(); + for ((source_path, source_export_name), capability) in &exported_object_literals { + let source_prefix = compute_module_prefix(source_path, &ctx.project_root); + for method in &capability.methods { + object_literal_method_candidates + .entry(method.name.clone()) + .or_default() + .push(perry_codegen::ObjectLiteralMethodCandidate { + class_id: capability.class_id, + source_prefix: source_prefix.clone(), + source_export_name: source_export_name.clone(), + source_global_id: capability.global_id, + shape_id_global: capability.shape_id_global.clone(), + method: method.clone(), + }); + } + } + for candidates in object_literal_method_candidates.values_mut() { + candidates.sort_unstable_by(|a, b| { + a.source_prefix + .cmp(&b.source_prefix) + .then_with(|| a.source_global_id.cmp(&b.source_global_id)) + .then_with(|| a.method.field_index.cmp(&b.method.field_index)) + .then_with(|| a.method.func_id.cmp(&b.method.func_id)) + }); + candidates.dedup_by(|a, b| { + a.source_prefix == b.source_prefix + && a.source_global_id == b.source_global_id + && a.method.field_index == b.method.field_index + && a.method.func_id == b.method.func_id + }); + } + let object_literal_method_candidates = std::sync::Arc::new(object_literal_method_candidates); let module_pool = rayon::ThreadPoolBuilder::new() .num_threads(module_jobs) .thread_name(|index| format!("perry-module-{index}")) @@ -4904,6 +4973,12 @@ pub fn run_with_parse_cache( namespace_imports, namespace_member_nested: namespace_member_nested.into_iter().collect(), imported_classes, + short_spread_method_candidates: std::sync::Arc::clone( + &short_spread_method_candidates, + ), + object_literal_method_candidates: std::sync::Arc::clone( + &object_literal_method_candidates, + ), imported_enums, imported_async_funcs: imported_async_set, type_aliases: type_alias_map, diff --git a/crates/perry/tests/issue_8772_short_packed_spread.rs b/crates/perry/tests/issue_8772_short_packed_spread.rs index f93c1bb279..157bd8f7e1 100644 --- a/crates/perry/tests/issue_8772_short_packed_spread.rs +++ b/crates/perry/tests/issue_8772_short_packed_spread.rs @@ -42,6 +42,9 @@ fn perry_bin() -> PathBuf { } fn target_debug_dir() -> PathBuf { + if let Some(runtime) = std::env::var_os("PERRY_TEST_RUNTIME_DIR") { + return PathBuf::from(runtime); + } let target = std::env::var_os("CARGO_TARGET_DIR") .map(PathBuf::from) .unwrap_or_else(|| workspace_root().join("target")); @@ -51,6 +54,12 @@ fn target_debug_dir() -> PathBuf { fn ensure_runtime_archive() { static BUILD_RUNTIME: Once = Once::new(); BUILD_RUNTIME.call_once(|| { + let runtime_dir = target_debug_dir(); + if runtime_dir.join("libperry_runtime.a").is_file() + && runtime_dir.join("libperry_stdlib.a").is_file() + { + return; + } let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); let mut command = Command::new(cargo); command @@ -232,9 +241,9 @@ fn repro_has_direct_empty_and_one_arms_and_matches_node_under_moving_gc() { !direct.contains("js_native_call_method_apply") && !direct.contains("js_spread_tail_fallback_args") ); - let fallback = named_blocks(invoke, &["short_spread.fallback"]); - assert!(fallback.contains("js_spread_tail_fallback_args")); - assert!(fallback.contains("js_native_call_method_apply_by_id")); + assert!(invoke.contains("short_spread.fallback")); + assert!(invoke.contains("js_spread_tail_fallback_args")); + assert!(invoke.contains("js_native_call_method_apply_by_id")); let artifacts = read_lowering_artifacts(temp.path()); for required in [ @@ -251,6 +260,41 @@ fn repro_has_direct_empty_and_one_arms_and_matches_node_under_moving_gc() { } } +#[test] +fn reverse_dependency_has_direct_arms_and_matches_node_under_moving_gc() { + let temp = tempfile::tempdir().expect("tempdir"); + copy_fixture(temp.path(), "generic.ts"); + copy_fixture(temp.path(), "reverse.ts"); + let binary = compile(temp.path(), "reverse.ts"); + + let node = run_node(temp.path(), "reverse.ts"); + assert_eq!(node, "90000900000\n"); + assert_eq!(run(&binary, temp.path(), false), node); + assert_eq!(run(&binary, temp.path(), true), node); + + let ir = std::fs::read_to_string(temp.path().join(".perry-trace/llvm/generic_ts.ll")) + .expect("read generic consumer LLVM IR"); + let invoke = function_ir(&ir, "__invoke("); + assert!(invoke.contains("call i32 @js_short_packed_spread_values(")); + assert!(invoke.contains("call i32 @js_method_direct_shape_class(")); + assert!(invoke.contains("@perry_method_reverse_ts__Position__reset(")); + assert!(invoke.contains("@perry_method_reverse_ts__Velocity__reset(")); + assert!(ir.contains("@perry_class_shape_id_reverse_ts__Position = external global i32")); + assert!(ir.contains("@perry_class_shape_id_reverse_ts__Velocity = external global i32")); + let direct = named_blocks( + invoke, + &[ + "short_spread.target0.arity0", + "short_spread.target0.arity1", + "short_spread.target1.arity0", + "short_spread.target1.arity1", + ], + ); + assert!(!direct.contains("js_native_call_method_apply")); + assert!(invoke.contains("short_spread.fallback")); + assert!(invoke.contains("js_native_call_method_apply_by_id")); +} + #[test] fn every_exotic_spread_and_dispatch_case_matches_node_under_moving_gc() { let temp = tempfile::tempdir().expect("tempdir"); @@ -280,3 +324,37 @@ fn throwing_iterator_matches_node_under_moving_gc() { ); } } + +#[test] +fn mixed_math_fixed_prefix_and_spread_tail_matches_node_under_moving_gc() { + let temp = tempfile::tempdir().expect("tempdir"); + std::fs::write( + temp.path().join("math.ts"), + r#" +const rows = [[], [0], [0, 1], [2, 3], [3, 4, 5]]; +for (const values of rows) { + console.log(JSON.stringify({ + values, + max: Math.max(-1, ...values), + min: Math.min(99, ...values), + })); +} +"#, + ) + .expect("write mixed Math spread fixture"); + let binary = compile(temp.path(), "math.ts"); + let node = run_node(temp.path(), "math.ts"); + assert_eq!(run(&binary, temp.path(), false), node); + assert_eq!(run(&binary, temp.path(), true), node); + + let ir = std::fs::read_to_string(temp.path().join(".perry-trace/llvm/math_ts.ll")) + .expect("read mixed Math spread LLVM IR"); + assert!( + ir.contains("js_native_call_method_apply_by_id"), + "mixed fixed/spread Math calls must retain iterator-aware apply\n{ir}" + ); + assert!( + !ir.contains("call double @js_math_max2") && !ir.contains("call double @js_math_min2"), + "the spread array must not be coerced as one scalar operand\n{ir}" + ); +} diff --git a/crates/perry/tests/issue_8773_closure_capture_packed_loops.rs b/crates/perry/tests/issue_8773_closure_capture_packed_loops.rs index 23bfdac554..919a0024f2 100644 --- a/crates/perry/tests/issue_8773_closure_capture_packed_loops.rs +++ b/crates/perry/tests/issue_8773_closure_capture_packed_loops.rs @@ -10,6 +10,13 @@ fn perry_bin() -> PathBuf { } fn runtime_dir() -> PathBuf { + if let Some(target_dir) = std::env::var_os("CARGO_TARGET_DIR") { + return PathBuf::from(target_dir).join(if cfg!(debug_assertions) { + "debug" + } else { + "release" + }); + } PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../..") .join("target") @@ -94,8 +101,16 @@ fn named_blocks(ir: &str, prefixes: &[&str]) -> String { fn nested_closure_capture_uses_live_guards_and_direct_fast_reads() { let dir = tempfile::tempdir().expect("tempdir"); let source = r#" -class Query extends Array {} -class Archetype extends Array {} +class Query extends Array { + archetypes = this; + ecs = 1; +} +class Archetype extends Array { + sset = 1; + entities = this; + mask = 0; + change: any[] = []; +} function setup(entityCount: number) { const query = new Query(); @@ -103,11 +118,16 @@ function setup(entityCount: number) { for (let i = 0; i < entityCount; i++) archetype.push(i); query.push(archetype); const values = new Uint32Array(entityCount); + const left = new Uint32Array(entityCount); + const right = new Uint32Array(entityCount); function system() { for (let i = 0, length = query.length; i < length; i++) { const current = query[i]; for (let j = 0, length = current.length; j < length; j++) { + const temp = left[current[j]]; + left[current[j]] = right[current[j]]; + right[current[j]] = temp; values[current[j]] += 1; } } @@ -335,3 +355,46 @@ make()(); ); } } + +#[test] +fn nested_read_miss_does_not_replay_prior_effects() { + let dir = tempfile::tempdir().expect("tempdir"); + let source = r#" +function make() { + const query: any[] = [[5]]; + const effects: any[] = [0]; + let hits = 0; + Object.defineProperty(effects, "0", { + get() { + hits += 1; + delete query[0][0]; + return 7; + } + }); + + return () => { + let text = ""; + for (let i = 0, length = query.length; i < length; i++) { + const current = query[i]; + for (let j = 0, length = current.length; j < length; j++) { + const first = current[j]; + const trigger = effects[j]; + const second = current[j]; + text += first + "|" + trigger + "|" + second + "|" + hits; + } + } + return text; + }; +} + +console.log(make()()); +"#; + let (binary, _) = compile(dir.path(), source, false); + for moving_gc in [false, true] { + assert_output( + &run(&binary, dir.path(), moving_gc), + "5|7|undefined|1\n", + moving_gc, + ); + } +} diff --git a/crates/perry/tests/issue_8774_argument_shape_clones.rs b/crates/perry/tests/issue_8774_argument_shape_clones.rs index 96abd20e4e..0c172b5f0c 100644 --- a/crates/perry/tests/issue_8774_argument_shape_clones.rs +++ b/crates/perry/tests/issue_8774_argument_shape_clones.rs @@ -291,7 +291,10 @@ fn stable_argument_clones_are_direct_reported_and_moving_gc_safe() { assert!(ir.contains(&format!("call double @{clone}("))); assert!(ir.contains(&format!("call double @{public}("))); } - assert!(ir.contains("pshape_arg.fallback")); + assert!( + !ir.contains("pshape_arg.fallback"), + "fresh contained locals should not repay their exact-shape proof at the call site" + ); let records = read_native_records(temp.path()); for method in ["add", "hash", "clear"] { @@ -344,8 +347,8 @@ fn guard_failures_match_node_and_unsafe_parameters_stay_generic() { ); } assert!( - !ir.contains("ForeignReader__read$pshape_args"), - "an imported argument class must stay on the generic route:\n{ir}" + ir.contains("ForeignReader__read$pshape_args"), + "a local method must be able to guard an imported argument layout:\n{ir}" ); let alias_clone = "perry_method_main_ts__AliasReader__read$pshape_args"; let _alias_clone_body = function_body(&ir, &format!("@{alias_clone}(")); @@ -353,5 +356,8 @@ fn guard_failures_match_node_and_unsafe_parameters_stay_generic() { !ir.contains(&format!("call double @{alias_clone}(")), "a receiver/argument alias must never enter the argument clone:\n{ir}" ); - assert!(ir.contains("pshape_arg.fallback")); + assert!( + !ir.contains("pshape_arg.fallback"), + "all selected semantic-fixture routes originate at fresh contained locals" + ); } diff --git a/crates/perry/tests/issue_8775_imported_object_specialization.rs b/crates/perry/tests/issue_8775_imported_object_specialization.rs index b89262d24f..f5a76c6e10 100644 --- a/crates/perry/tests/issue_8775_imported_object_specialization.rs +++ b/crates/perry/tests/issue_8775_imported_object_specialization.rs @@ -78,6 +78,7 @@ fn copy_fixture(dir: &Path) { "package.json", "adapter.js", "barrel.js", + "runner.js", "main.js", "semantics.js", ] { @@ -194,16 +195,21 @@ fn stable_imported_object_methods_use_guarded_direct_closure_bodies() { .expect("read main LLVM IR"); let adapter_ir = std::fs::read_to_string(temp.path().join(".perry-trace/llvm/adapter_js.ll")) .expect("read adapter LLVM IR"); + let runner_ir = std::fs::read_to_string(temp.path().join(".perry-trace/llvm/runner_js.ll")) + .expect("read runner LLVM IR"); for func_id in [6, 7, 8, 9] { let symbol = format!("perry_closure_adapter_js__{func_id}"); assert!( adapter_ir.contains(&format!("define double @{symbol}(")), "producer closure must have external linkage: {symbol}" ); - let fast_block = main_ir + } + for func_id in [7, 8, 9] { + let symbol = format!("perry_closure_adapter_js__{func_id}"); + let fast_block = runner_ir .split("\n\n") - .find(|block| block.contains("imported_object.direct.") && block.contains(&symbol)) - .unwrap_or_else(|| panic!("no imported-object direct block for {symbol}:\n{main_ir}")); + .find(|block| block.contains("object_method_cache.direct.") && block.contains(&symbol)) + .unwrap_or_else(|| panic!("no dynamic-object direct block for {symbol}:\n{runner_ir}")); assert!( !fast_block.contains("js_native_call_method_by_id") && !fast_block.contains("js_typed_feedback_native_call_method_by_id") @@ -212,6 +218,18 @@ fn stable_imported_object_methods_use_guarded_direct_closure_bodies() { ); } assert!(main_ir.contains("call double @js_native_call_method_by_id")); + assert!(runner_ir.contains("call double @js_native_call_method_by_id")); + assert!(main_ir.contains("call i32 @js_closure_exact_func_guard")); + assert!(runner_ir.contains("call i32 @js_closure_exact_func_guard")); + assert!(main_ir.contains("call i64 @js_object_own_method_cache_miss")); + assert!(runner_ir.contains("call i64 @js_object_own_method_cache_miss")); + assert!(!main_ir.contains("call i32 @js_typed_feedback_closure_direct_call_guard")); + assert!(!runner_ir.contains("call i32 @js_typed_feedback_closure_direct_call_guard")); + assert!( + main_ir.contains("object_method_cache.direct.") + && main_ir.contains("call double @__perry_wrap_perry_fn_runner_js__"), + "the no-this concise wrapper method must be published and selected:\n{main_ir}" + ); assert!( main_ir .lines() @@ -226,16 +244,32 @@ fn stable_imported_object_methods_use_guarded_direct_closure_bodies() { .filter(|record| record["consumer"] == "imported_object_literal_method_direct_call") .collect(); assert!( - selected.len() >= 5, + !selected.is_empty(), "missing selected records: {records:#?}" ); for record in selected { let notes = record_notes(record); assert!(notes.contains(&"receiver_provenance=imported_object_literal_metadata")); assert!(notes.contains(&"generic_dispatch_fallback=js_native_call_method_by_id")); - assert!( - notes.contains(&"guards=receiver_identity,exact_shape,own_data_slot,function_identity") - ); + assert!(notes + .contains(&"guards=receiver_identity,live_shape_cache,own_key_slot,function_identity")); + assert!(notes.contains(&"append_only_shape_successors=revalidated_and_cached")); + } + let dynamic: Vec<_> = records + .iter() + .filter(|record| record["consumer"] == "whole_program_object_literal_method_direct_call") + .collect(); + assert!( + dynamic.len() >= 4, + "wrapper-parameter calls were not selected: {records:#?}" + ); + for record in dynamic { + let notes = record_notes(record); + assert!(notes.contains(&"receiver_provenance=dynamic_local_with_producer_candidates")); + assert!(notes.contains(&"generic_dispatch_fallback=js_native_call_method_by_id")); + assert!(notes + .contains(&"guards=receiver_identity,live_shape_cache,own_key_slot,function_identity")); + assert!(notes.contains(&"append_only_shape_successors=revalidated_and_cached")); } } diff --git a/scripts/gc_root_dominance_check.py b/scripts/gc_root_dominance_check.py index b160cbeb0a..90ba4c3bc5 100755 --- a/scripts/gc_root_dominance_check.py +++ b/scripts/gc_root_dominance_check.py @@ -473,7 +473,8 @@ def build_cfg(f): # pure value predicates / bit twiddling "js_is_truthy", "js_nanbox_get_pointer", # inline-cache guards: pure reads - "js_typed_feedback_closure_direct_call_guard", + "js_typed_feedback_closure_direct_call_guard", "js_closure_exact_func_guard", + "js_object_own_method_cache_miss", # ctor identity selection "js_ctor_return_override", "llvm.lifetime.start.p0", "llvm.lifetime.end.p0", diff --git a/test-files/fixtures/issue_8772_short_packed_spread/generic.ts b/test-files/fixtures/issue_8772_short_packed_spread/generic.ts new file mode 100644 index 0000000000..47a1558445 --- /dev/null +++ b/test-files/fixtures/issue_8772_short_packed_spread/generic.ts @@ -0,0 +1,7 @@ +export function invoke( + instance: any, + entity: { id: number }, + args: number[], +): void { + instance.reset(entity, ...args); +} diff --git a/test-files/fixtures/issue_8772_short_packed_spread/reverse.ts b/test-files/fixtures/issue_8772_short_packed_spread/reverse.ts new file mode 100644 index 0000000000..08cac4497c --- /dev/null +++ b/test-files/fixtures/issue_8772_short_packed_spread/reverse.ts @@ -0,0 +1,30 @@ +import { invoke } from "./generic.ts"; + +class Position { + x: number; + constructor() { this.x = 0; } + reset(entity: { id: number }, delta: number = 1): void { + this.x = entity.id + delta; + } +} + +class Velocity { + dx: number; + constructor() { this.dx = 0; } + reset(entity: { id: number }, delta: number = 2): void { + this.dx = entity.id + delta; + } +} + +const position = new Position(); +const velocity = new Velocity(); +const empty: number[] = []; +const one: number[] = [3]; +let checksum = 0; +for (let i = 0; i < 300000; i++) { + const entity = { id: i }; + invoke(position, entity, empty); + invoke(velocity, entity, one); + checksum += position.x + velocity.dx; +} +console.log(checksum); diff --git a/test-files/fixtures/issue_8774_argument_shapes/main.ts b/test-files/fixtures/issue_8774_argument_shapes/main.ts index bd9c5edd9b..cca3f50f73 100644 --- a/test-files/fixtures/issue_8774_argument_shapes/main.ts +++ b/test-files/fixtures/issue_8774_argument_shapes/main.ts @@ -54,7 +54,8 @@ class Registry { } class ForeignReader { - // Imported class metadata is intentionally not a local clone capability. + // The method body is local while its parameter layout crosses a re-export. + // The imported class id + ShapeId guard makes this a valid local clone. read(entity: Foreign): number { return entity.id + entity.components.length; } diff --git a/test-files/fixtures/issue_8775_imported_object/main.js b/test-files/fixtures/issue_8775_imported_object/main.js index 66f62d231f..3406f33c90 100644 --- a/test-files/fixtures/issue_8775_imported_object/main.js +++ b/test-files/fixtures/issue_8775_imported_object/main.js @@ -1,13 +1,11 @@ import adapter from "./adapter.js"; +import runner from "./runner.js"; adapter.setup(); let checksum = 0; const iterations = 200_000; for (let i = 0; i < iterations; i++) { - const entity = adapter.createEntity(i); - adapter.addComponent(entity); - adapter.addComponent(entity); - checksum += adapter.destroyEntity(entity); + checksum += runner.perform(adapter, i); } console.log(JSON.stringify({ checksum, diff --git a/test-files/fixtures/issue_8775_imported_object/runner.js b/test-files/fixtures/issue_8775_imported_object/runner.js new file mode 100644 index 0000000000..c562033146 --- /dev/null +++ b/test-files/fixtures/issue_8775_imported_object/runner.js @@ -0,0 +1,10 @@ +export default { + // Intentionally does not read `this`: this is the concise-method HIR shape + // used by ddmills' `suite.perform(ctx)` wrapper. + perform(ctx, id) { + const entity = ctx.createEntity(id); + ctx.addComponent(entity); + ctx.addComponent(entity); + return ctx.destroyEntity(entity); + }, +}; From 4ca4686ed5081df2472ab11d9e90789e4a700510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 21:04:04 +0200 Subject: [PATCH 2/4] docs: record ECS integration follow-through --- changelog.d/8833-ecs-integration-followthrough.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 changelog.d/8833-ecs-integration-followthrough.md diff --git a/changelog.d/8833-ecs-integration-followthrough.md b/changelog.d/8833-ecs-integration-followthrough.md new file mode 100644 index 0000000000..e897c1c4d1 --- /dev/null +++ b/changelog.d/8833-ecs-integration-followthrough.md @@ -0,0 +1,9 @@ +The ECS benchmark specializations now cover their real cross-module integration paths: short +packed spread and exact argument-shape calls receive producer metadata, imported object-literal +methods retain exact own-method capabilities through adapter parameters, and closure-captured +packed loops version nested arrays derived from guarded indexed reads. Every path keeps a guarded +generic side exit. The audit also fixes mixed fixed/spread `Math` calls incorrectly treating their +tail array as a scalar argument and restores iterator-protocol fallback for proxy and Array-subclass +spread tails. On the controlled M1 cohort, the corrected full Wolf workload is 1.73x faster and the +imported-method `perform-ecs` workload is 11.05% faster; the short-spread and argument-clone slices +activate correctly but remain performance-neutral. From 0b37e2758506aeed85c3a1040c369dda5773a330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 21:45:19 +0200 Subject: [PATCH 3/4] fix: harden ECS specializations after review --- crates/perry-codegen/src/codegen/helpers.rs | 27 ++++ crates/perry-codegen/src/codegen/mod.rs | 45 ++---- .../src/collectors/object_literal_exports.rs | 43 +++--- .../src/collectors/proven_args.rs | 75 ++++++++-- .../perry-codegen/src/collectors/ptr_shape.rs | 128 +----------------- .../src/collectors/ptr_shape_entry.rs | 119 ++++++++++++++++ .../property_get/imported_object.rs | 9 +- .../src/runtime_decls/objects.rs | 2 +- .../src/object/native_call_method.rs | 4 +- .../src/typed_feedback/guards.rs | 38 +++--- .../perry-runtime/src/typed_feedback/tests.rs | 21 +-- ...sue_8775_imported_object_specialization.rs | 4 +- scripts/gc_root_dominance_check.py | 1 + scripts/shape_descriptor_census_baseline.json | 2 +- 14 files changed, 287 insertions(+), 231 deletions(-) create mode 100644 crates/perry-codegen/src/collectors/ptr_shape_entry.rs diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 7926d4e5a3..26a690ed44 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -1029,6 +1029,33 @@ pub(super) fn sanitize_member(name: &str) -> String { s } +/// Reserve the keys-global symbol for one source class. Keep this single +/// implementation shared by capability harvesting and module emission: both +/// passes must assign collision suffixes in the same source order or a +/// harvested ShapeId external can name a global the producer never defines. +pub(crate) fn unique_class_keys_global( + module_prefix: &str, + class_name: &str, + used: &mut std::collections::HashSet, +) -> String { + let base = format!( + "perry_class_keys_{}__{}", + module_prefix, + sanitize(class_name) + ); + if used.insert(base.clone()) { + return base; + } + let mut suffix = 1u32; + loop { + let candidate = format!("{base}_{suffix}"); + if used.insert(candidate.clone()) { + return candidate; + } + suffix += 1; + } +} + /// Host default triple. /// Host-default LLVM target triple. Used when `CompileOptions.target` /// is `None`. Also re-exposed via `pub(crate)` so `linker.rs` can pin diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 1c7c480776..d800568c1e 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -191,7 +191,7 @@ mod declared_string_add_tests; mod emission_order_tests; mod entry; pub mod entry_outline; -mod func_registry; +pub(crate) mod func_registry; mod function; #[cfg(test)] mod guarded_undefined_method_tests; @@ -355,23 +355,8 @@ pub fn short_spread_method_capabilities(hir: &HirModule) -> Vec Result> // `new ClassName()` site still resolves to the right global. let mut used_class_keys_globals: std::collections::HashSet = std::collections::HashSet::new(); - fn unique_global(base: String, used: &mut std::collections::HashSet) -> String { - if used.insert(base.clone()) { - return base; - } - let mut n = 1u32; - loop { - let candidate = format!("{base}_{n}"); - if used.insert(candidate.clone()) { - return candidate; - } - n += 1; - } - } - for c in &hir.classes { - let global_name = unique_global( - format!("perry_class_keys_{}__{}", module_prefix, sanitize(&c.name)), + let global_name = helpers::unique_class_keys_global( + &module_prefix, + &c.name, &mut used_class_keys_globals, ); llmod.add_internal_global(&global_name, I64, "0"); @@ -1348,8 +1320,9 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> if class_keys_globals_map.contains_key(&c.name) { continue; } - let global_name = unique_global( - format!("perry_class_keys_{}__{}", module_prefix, sanitize(&c.name)), + let global_name = helpers::unique_class_keys_global( + &module_prefix, + &c.name, &mut used_class_keys_globals, ); llmod.add_internal_global(&global_name, I64, "0"); diff --git a/crates/perry-codegen/src/collectors/object_literal_exports.rs b/crates/perry-codegen/src/collectors/object_literal_exports.rs index 7eceb2673c..c397d2241e 100644 --- a/crates/perry-codegen/src/collectors/object_literal_exports.rs +++ b/crates/perry-codegen/src/collectors/object_literal_exports.rs @@ -18,6 +18,7 @@ fn local_get_is(expr: &Expr, expected: u32) -> bool { fn eligible_method( hir: &Module, + func_names: &HashMap, class: &perry_hir::Class, key: &str, value: &Expr, @@ -48,11 +49,7 @@ fn eligible_method( if function.is_async || function.is_generator { return None; } - let target = format!( - "__perry_wrap_perry_fn_{}__{}", - source_prefix, - crate::codegen::helpers::sanitize(&function.name) - ); + let target = format!("__perry_wrap_{}", func_names.get(func_id)?); (*func_id, &function.params, target) } _ => return None, @@ -79,23 +76,11 @@ fn class_shape_id_global(hir: &Module, class_name: &str) -> Option { let source_prefix = crate::codegen::helpers::sanitize(&hir.name); let mut used = HashSet::new(); for class in &hir.classes { - let base = format!( - "perry_class_keys_{}__{}", - source_prefix, - crate::codegen::helpers::sanitize(&class.name) + let keys_global = crate::codegen::helpers::unique_class_keys_global( + &source_prefix, + &class.name, + &mut used, ); - let keys_global = if used.insert(base.clone()) { - base - } else { - let mut suffix = 1u32; - loop { - let candidate = format!("{base}_{suffix}"); - if used.insert(candidate.clone()) { - break candidate; - } - suffix += 1; - } - }; if class.name == class_name { return Some(crate::typed_shape::shape_id_global_name_from_keys_global( &keys_global, @@ -107,6 +92,7 @@ fn class_shape_id_global(hir: &Module, class_name: &str) -> Option { fn capability_from_init( hir: &Module, + func_names: &HashMap, global_id: u32, init: &Expr, ) -> Option { @@ -167,7 +153,10 @@ fn capability_from_init( let Expr::String(key) = index.as_ref() else { return None; }; - final_methods.insert(key.clone(), eligible_method(hir, class, key, value)); + final_methods.insert( + key.clone(), + eligible_method(hir, func_names, class, key, value), + ); } Stmt::Expr(Expr::Call { callee, args, .. }) => { let Expr::ExternFuncRef { name, .. } = callee.as_ref() else { @@ -182,7 +171,10 @@ fn capability_from_init( if !local_get_is(receiver, param.id) { return None; } - final_methods.insert(key.clone(), eligible_method(hir, class, key, value)); + final_methods.insert( + key.clone(), + eligible_method(hir, func_names, class, key, value), + ); } Stmt::Return(Some(value)) if local_get_is(value, param.id) && !saw_return => { saw_return = true; @@ -226,6 +218,9 @@ fn capability_from_init( pub(crate) fn exported_object_literal_capabilities( hir: &Module, ) -> HashMap { + let source_prefix = crate::codegen::helpers::sanitize(&hir.name); + let func_names = + crate::codegen::func_registry::build_func_registry(hir, &source_prefix).func_names; let exported_objects: HashSet<&str> = hir.exported_objects.iter().map(String::as_str).collect(); let mut exported_locals: HashSet<&str> = exported_objects.clone(); for export in &hir.exports { @@ -250,7 +245,7 @@ pub(crate) fn exported_object_literal_capabilities( if !exported_locals.contains(name.as_str()) { continue; } - if let Some(capability) = capability_from_init(hir, *id, init) { + if let Some(capability) = capability_from_init(hir, &func_names, *id, init) { by_local.insert(name.clone(), capability); } } diff --git a/crates/perry-codegen/src/collectors/proven_args.rs b/crates/perry-codegen/src/collectors/proven_args.rs index 6a6e589528..251e7d3e73 100644 --- a/crates/perry-codegen/src/collectors/proven_args.rs +++ b/crates/perry-codegen/src/collectors/proven_args.rs @@ -81,6 +81,7 @@ pub(crate) fn method_proven_shape_args( field_reads: HashSet::new(), safe: true, escaped: false, + read_in_region: false, }; use_check.walk_stmts(&method.body); if !use_check.safe || use_check.field_reads.is_empty() { @@ -199,6 +200,10 @@ struct PrefixContainedParamUse { field_reads: HashSet, safe: bool, escaped: bool, + /// Whether the currently active repeated region performed any accepted + /// direct field read. This is independent of `field_reads` cardinality: + /// rereading a field already seen before the loop must still count. + read_in_region: bool, } impl PrefixContainedParamUse { @@ -229,18 +234,16 @@ impl PrefixContainedParamUse { } } Stmt::While { condition, body } => { - let field_count = self.field_reads.len(); - let escaped = self.escaped; + let outer = self.enter_repeated_region(); self.walk_expr(condition); self.walk_stmts(body); - self.finish_repeated_region(field_count, escaped); + self.finish_repeated_region(outer); } Stmt::DoWhile { condition, body } => { - let field_count = self.field_reads.len(); - let escaped = self.escaped; + let outer = self.enter_repeated_region(); self.walk_stmts(body); self.walk_expr(condition); - self.finish_repeated_region(field_count, escaped); + self.finish_repeated_region(outer); } Stmt::For { init, @@ -251,8 +254,7 @@ impl PrefixContainedParamUse { if let Some(init) = init { self.walk_stmt(init); } - let field_count = self.field_reads.len(); - let escaped = self.escaped; + let outer = self.enter_repeated_region(); if let Some(condition) = condition { self.walk_expr(condition); } @@ -260,7 +262,7 @@ impl PrefixContainedParamUse { self.walk_expr(update); } self.walk_stmts(body); - self.finish_repeated_region(field_count, escaped); + self.finish_repeated_region(outer); } Stmt::Try { body, @@ -300,13 +302,21 @@ impl PrefixContainedParamUse { } } - fn finish_repeated_region(&mut self, field_count: usize, escaped: bool) { + fn enter_repeated_region(&mut self) -> (bool, bool) { + let outer = (self.escaped, self.read_in_region); + self.read_in_region = false; + outer + } + + fn finish_repeated_region(&mut self, outer: (bool, bool)) { + let (escaped_before, read_before) = outer; // Even when every first-iteration read precedes publication, the next // iteration would perform that read after publication. Refuse the // whole-parameter overlay when a repeated region contains both. - if !escaped && self.escaped && self.field_reads.len() > field_count { + if !escaped_before && self.escaped && self.read_in_region { self.safe = false; } + self.read_in_region |= read_before; } fn walk_expr(&mut self, expr: &Expr) { @@ -324,6 +334,7 @@ impl PrefixContainedParamUse { self.safe = false; } else { self.field_reads.insert(property.clone()); + self.read_in_region = true; } } // A direct store/update has frozen/sealed and setter semantics not @@ -347,6 +358,48 @@ impl PrefixContainedParamUse { #[cfg(test)] mod tests { + use super::*; + + fn field_read(param_id: u32) -> Expr { + Expr::PropertyGet { + object: Box::new(Expr::LocalGet(param_id)), + property: "id".to_string(), + byte_offset: 0, + } + } + + #[test] + fn repeated_reread_before_publication_rejects_entry_shape_proof() { + let param_id = 7; + let mut use_check = PrefixContainedParamUse { + param_id, + field_reads: HashSet::new(), + safe: true, + escaped: false, + read_in_region: false, + }; + use_check.walk_stmts(&[ + Stmt::Expr(field_read(param_id)), + Stmt::While { + condition: Expr::Bool(true), + body: vec![ + // `id` is already in the HashSet before this loop. The + // repeated-region proof must count this occurrence, not a + // set-size delta, because the next iteration reads after + // the publication below. + Stmt::Expr(field_read(param_id)), + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::LocalGet(99)), + args: vec![Expr::LocalGet(param_id)], + type_args: Vec::new(), + byte_offset: 0, + }), + ], + }, + ]); + assert!(!use_check.safe); + } + /// `$pshape_args` is an internal direct-call capability. Keep every place /// that can spell its suffix visible here so a future vtable/indirect-call /// registration fails the same kind of reachability ratchet as the diff --git a/crates/perry-codegen/src/collectors/ptr_shape.rs b/crates/perry-codegen/src/collectors/ptr_shape.rs index 0bed0153ce..f8992c85d1 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape.rs @@ -183,37 +183,6 @@ pub struct PtrShapeLocal { pub report_name: Option, } -/// Whether an expression node is a §5.2 shape barrier for the module-wide -/// first-increment kill rule. Targets are NOT inspected — any occurrence -/// disables all `Ptr` promotion in the module. -pub(crate) fn expr_is_shape_barrier(expr: &Expr) -> bool { - match expr { - Expr::ObjectDefineProperty(..) - | Expr::ObjectDefineProperties(..) - | Expr::ReflectDefineProperty { .. } - | Expr::ObjectSetPrototypeOf(..) - | Expr::ReflectSetPrototypeOf { .. } - | Expr::ReflectSet { .. } - | Expr::ReflectDelete { .. } - | Expr::ReflectPreventExtensions(..) - | Expr::Delete(..) - | Expr::ProxyNew { .. } => true, - // `__proto__` writes mutate the prototype chain of an arbitrary - // object. (Reads and `.prototype` naming are handled by the - // dispatch-stability facts; only writes are shape barriers.) - Expr::PropertySet { property, .. } | Expr::PropertyUpdate { property, .. } => { - property == "__proto__" - } - Expr::PutValueSet { key, .. } => { - matches!(key.as_ref(), Expr::String(k) if k == "__proto__") - } - Expr::IndexSet { index, .. } => { - matches!(index.as_ref(), Expr::String(k) if k == "__proto__") - } - _ => false, - } -} - /// Compile-time visibility: one stderr line per shape-proven local, plus a /// process-wide running count. Only under `PERRY_REPSEL_DEBUG=1`. /// @@ -287,97 +256,12 @@ fn report_early_bail( } } -/// Entry point: collect the shape-proven pointer locals of one lowered region. -/// -/// `not_bigint_locals` feeds the numeric-field proof (a `Sub`/`Div`/bitwise -/// over provably-non-BigInt operands is a Number by spec). -pub(crate) fn collect_shape_proven_ptr_locals( - stmts: &[Stmt], - boxed_vars: &HashSet, - module_globals: &HashMap, - classes: &HashMap, - module_dispatch: &ModuleDispatchFacts, - not_bigint_locals: &HashSet, - element_facts: &ElementShapeFacts, -) -> HashMap { - collect_shape_proven_ptr_locals_and_element_fields( - stmts, - boxed_vars, - module_globals, - classes, - module_dispatch, - not_bigint_locals, - element_facts, - &HashSet::new(), - ) - .0 -} - -/// Collect pointer-local facts plus the group-wide numeric layouts of proven -/// element arrays. The latter includes arrays read only as `A[i].field`, which -/// have no element local to carry a [`PtrShapeLocal`] of their own. -pub(crate) fn collect_shape_proven_ptr_locals_and_element_fields( - stmts: &[Stmt], - boxed_vars: &HashSet, - module_globals: &HashMap, - classes: &HashMap, - module_dispatch: &ModuleDispatchFacts, - not_bigint_locals: &HashSet, - element_facts: &ElementShapeFacts, - numeric_param_seeds: &HashSet, -) -> (HashMap, HashMap>) { - collect_shape_proven_ptr_locals_impl( - stmts, - boxed_vars, - module_globals, - classes, - module_dispatch, - not_bigint_locals, - element_facts, - numeric_param_seeds, - CollectionPurpose::UnguardedRepresentation, - ) -} - -/// Containment facts that may be consumed only by a `$pshape_args` call-site -/// guard. Unlike a guard-free `Ptr` representation, an unrelated -/// module barrier cannot invalidate this fact: the fresh object has not -/// escaped, and the route rechecks its live class and ShapeId immediately -/// before entering the clone. These facts must never feed ordinary field or -/// method lowering. -pub(crate) fn collect_guarded_argument_route_locals( - stmts: &[Stmt], - boxed_vars: &HashSet, - module_globals: &HashMap, - classes: &HashMap, - module_dispatch: &ModuleDispatchFacts, - not_bigint_locals: &HashSet, - element_facts: &ElementShapeFacts, - numeric_param_seeds: &HashSet, -) -> HashMap { - // This second pass is a proof query, not a guard-free representation - // selection. Suppress Ptr report rows so it cannot claim that a - // barrier-gated local received the broader optimization. - let _quiet = report::SuppressScope::new(); - let (mut facts, _) = collect_shape_proven_ptr_locals_impl( - stmts, - boxed_vars, - module_globals, - classes, - module_dispatch, - not_bigint_locals, - element_facts, - numeric_param_seeds, - CollectionPurpose::GuardedArgumentRoute, - ); - // The guarded route consumes class/containment only. Do not carry a raw - // numeric-field representation claim into this deliberately narrower map. - for fact in facts.values_mut() { - fact.numeric_fields.clear(); - fact.report_name = None; - } - facts -} +#[path = "ptr_shape_entry.rs"] +mod entry; +pub(crate) use entry::{ + collect_guarded_argument_route_locals, collect_shape_proven_ptr_locals, + collect_shape_proven_ptr_locals_and_element_fields, expr_is_shape_barrier, +}; #[derive(Clone, Copy, PartialEq, Eq)] enum CollectionPurpose { diff --git a/crates/perry-codegen/src/collectors/ptr_shape_entry.rs b/crates/perry-codegen/src/collectors/ptr_shape_entry.rs new file mode 100644 index 0000000000..a323c43341 --- /dev/null +++ b/crates/perry-codegen/src/collectors/ptr_shape_entry.rs @@ -0,0 +1,119 @@ +//! Public entry points and barrier classification for the `Ptr` proof. + +use super::*; + +/// Whether an expression node is a §5.2 shape barrier for the module-wide +/// first-increment kill rule. Targets are NOT inspected — any occurrence +/// disables all `Ptr` promotion in the module. +pub(crate) fn expr_is_shape_barrier(expr: &Expr) -> bool { + match expr { + Expr::ObjectDefineProperty(..) + | Expr::ObjectDefineProperties(..) + | Expr::ReflectDefineProperty { .. } + | Expr::ObjectSetPrototypeOf(..) + | Expr::ReflectSetPrototypeOf { .. } + | Expr::ReflectSet { .. } + | Expr::ReflectDelete { .. } + | Expr::ReflectPreventExtensions(..) + | Expr::Delete(..) + | Expr::ProxyNew { .. } => true, + // `__proto__` writes mutate the prototype chain of an arbitrary + // object. Reads and class-prototype naming are handled by the + // dispatch-stability facts; only writes are shape barriers. + Expr::PropertySet { property, .. } | Expr::PropertyUpdate { property, .. } => { + property == "__proto__" + } + Expr::PutValueSet { key, .. } => { + matches!(key.as_ref(), Expr::String(k) if k == "__proto__") + } + Expr::IndexSet { index, .. } => { + matches!(index.as_ref(), Expr::String(k) if k == "__proto__") + } + _ => false, + } +} + +/// Entry point: collect the shape-proven pointer locals of one lowered region. +/// `not_bigint_locals` feeds the numeric-field proof. +pub(crate) fn collect_shape_proven_ptr_locals( + stmts: &[Stmt], + boxed_vars: &HashSet, + module_globals: &HashMap, + classes: &HashMap, + module_dispatch: &ModuleDispatchFacts, + not_bigint_locals: &HashSet, + element_facts: &ElementShapeFacts, +) -> HashMap { + collect_shape_proven_ptr_locals_and_element_fields( + stmts, + boxed_vars, + module_globals, + classes, + module_dispatch, + not_bigint_locals, + element_facts, + &HashSet::new(), + ) + .0 +} + +/// Collect pointer-local facts plus group-wide numeric layouts of proven +/// element arrays, including arrays read only as `A[i].field`. +#[allow(clippy::too_many_arguments)] +pub(crate) fn collect_shape_proven_ptr_locals_and_element_fields( + stmts: &[Stmt], + boxed_vars: &HashSet, + module_globals: &HashMap, + classes: &HashMap, + module_dispatch: &ModuleDispatchFacts, + not_bigint_locals: &HashSet, + element_facts: &ElementShapeFacts, + numeric_param_seeds: &HashSet, +) -> (HashMap, HashMap>) { + collect_shape_proven_ptr_locals_impl( + stmts, + boxed_vars, + module_globals, + classes, + module_dispatch, + not_bigint_locals, + element_facts, + numeric_param_seeds, + CollectionPurpose::UnguardedRepresentation, + ) +} + +/// Containment facts consumable only beside a `$pshape_args` live guard. +#[allow(clippy::too_many_arguments)] +pub(crate) fn collect_guarded_argument_route_locals( + stmts: &[Stmt], + boxed_vars: &HashSet, + module_globals: &HashMap, + classes: &HashMap, + module_dispatch: &ModuleDispatchFacts, + not_bigint_locals: &HashSet, + element_facts: &ElementShapeFacts, + numeric_param_seeds: &HashSet, +) -> HashMap { + // This second pass is a proof query, not a guard-free representation + // selection. Suppress report rows for the broader optimization. + let _quiet = report::SuppressScope::new(); + let (mut facts, _) = collect_shape_proven_ptr_locals_impl( + stmts, + boxed_vars, + module_globals, + classes, + module_dispatch, + not_bigint_locals, + element_facts, + numeric_param_seeds, + CollectionPurpose::GuardedArgumentRoute, + ); + // The guarded route consumes class/containment only, never a raw numeric + // field representation claim. + for fact in facts.values_mut() { + fact.numeric_fields.clear(); + fact.report_name = None; + } + facts +} diff --git a/crates/perry-codegen/src/lower_call/property_get/imported_object.rs b/crates/perry-codegen/src/lower_call/property_get/imported_object.rs index d4a70bdbba..058558d7af 100644 --- a/crates/perry-codegen/src/lower_call/property_get/imported_object.rs +++ b/crates/perry-codegen/src/lower_call/property_get/imported_object.rs @@ -3,7 +3,7 @@ use anyhow::Result; use perry_hir::Expr; -use crate::expr::{lower_expr, unbox_to_i64, FnCtx}; +use crate::expr::{lower_expr, FnCtx}; use crate::native_value::LoweredValue; use crate::rooting::{any_operand_may_collect, open_rooted_group, Repr}; use crate::types::{DOUBLE, I1, I32, I64, I8, PTR}; @@ -113,16 +113,15 @@ fn emit_cached_own_method_guard( .block() .gep(DOUBLE, &fields, &[(I64, &field_index.to_string())]); let closure_value = ctx.block().load(DOUBLE, &slot); - let guard = ctx.block().call( - I32, + let fast_handle = ctx.block().call( + I64, "js_closure_exact_func_guard", &[ (DOUBLE, &closure_value), (PTR, &format!("@{closure_symbol}")), ], ); - let guard_passes = ctx.block().icmp_ne(I32, &guard, "0"); - let fast_handle = unbox_to_i64(ctx.block(), &closure_value); + let guard_passes = ctx.block().icmp_ne(I64, &fast_handle, "0"); let fast_end = ctx.block().label.clone(); ctx.block() .cond_br(&guard_passes, &direct_label, &cold_label); diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index 3e62b3c098..221cf470f0 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -231,7 +231,7 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { I32, &[I64, DOUBLE, PTR, I32, I32], ); - module.declare_function("js_closure_exact_func_guard", I32, &[DOUBLE, PTR]); + module.declare_function("js_closure_exact_func_guard", I64, &[DOUBLE, PTR]); module.declare_function( "js_object_own_method_cache_miss", I64, diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 57e721c2af..500a2cb0d8 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -539,8 +539,8 @@ pub unsafe extern "C-unwind" fn js_native_call_method_apply_by_id( /// Materialize `fixed..., ...spread` for the generic branch of a short packed /// spread callsite. The fast branch has already evaluated all operands; doing /// the fallback assembly here preserves that source order without re-running -/// an expression, and [`crate::object::js_array_like_to_array`] preserves the -/// the full iterator protocol for every proof miss. +/// an expression, and [`crate::array::array_from_spread_value`] preserves the +/// full iterator protocol for every proof miss. /// /// The returned array is consumed immediately by /// [`js_native_call_method_apply_by_id`]. Every input and both arrays are held diff --git a/crates/perry-runtime/src/typed_feedback/guards.rs b/crates/perry-runtime/src/typed_feedback/guards.rs index 4d8a619944..ea3763f35d 100644 --- a/crates/perry-runtime/src/typed_feedback/guards.rs +++ b/crates/perry-runtime/src/typed_feedback/guards.rs @@ -1175,24 +1175,32 @@ pub extern "C" fn js_typed_feedback_closure_direct_call_guard( /// header validation used by the universal dispatcher: arbitrary replacement /// values, small handle-band ids, and bound-function sentinels must miss the /// direct arm without ever being dereferenced or called as code. +fn closure_ptr_from_value_bits(bits: u64) -> *const crate::closure::ClosureHeader { + let addr = if (bits & TAG_MASK) == POINTER_TAG { + (bits & POINTER_MASK) as usize + } else if bits >> 48 == 0 && crate::value::addr_class::is_above_handle_band(bits as usize) { + bits as usize + } else { + 0 + }; + addr as *const crate::closure::ClosureHeader +} + #[no_mangle] pub extern "C" fn js_closure_exact_func_guard( closure_value: f64, expected_func_ptr: *const u8, -) -> i32 { +) -> u64 { if expected_func_ptr.is_null() { return 0; } - let bits = closure_value.to_bits(); - let raw_ptr = if (bits & TAG_MASK) == POINTER_TAG { - (bits & POINTER_MASK) as *const crate::closure::ClosureHeader - } else if (bits >> 48) == 0 && bits >= 0x10000 { - bits as *const crate::closure::ClosureHeader - } else { - std::ptr::null() - }; + let raw_ptr = closure_ptr_from_value_bits(closure_value.to_bits()); let closure_ptr = crate::closure::clean_closure_ptr(raw_ptr); - (crate::closure::get_valid_func_ptr(closure_ptr) == expected_func_ptr) as i32 + if crate::closure::get_valid_func_ptr(closure_ptr) == expected_func_ptr { + closure_ptr as u64 + } else { + 0 + } } /// Revalidate and prime the shape token for an own object-literal method. @@ -1273,13 +1281,7 @@ pub unsafe extern "C" fn js_object_own_method_cache_miss( let fields = (object as *const u8).add(std::mem::size_of::()) as *const u64; let closure_bits = std::ptr::read(fields.add(field_index as usize)); - let raw_ptr = if (closure_bits & TAG_MASK) == POINTER_TAG { - (closure_bits & POINTER_MASK) as *const crate::closure::ClosureHeader - } else if (closure_bits >> 48) == 0 && closure_bits >= 0x10000 { - closure_bits as *const crate::closure::ClosureHeader - } else { - std::ptr::null() - }; + let raw_ptr = closure_ptr_from_value_bits(closure_bits); let closure = crate::closure::clean_closure_ptr(raw_ptr); if crate::closure::get_valid_func_ptr(closure) != expected_func_ptr { return 0; @@ -1323,7 +1325,7 @@ mod keep_guard_symbols { #[cfg(feature = "keepalive-anchors")] #[used] static G3: extern "C" fn(u64, f64, *const u8, u32, u32) -> i32 = js_typed_feedback_closure_direct_call_guard; #[cfg(feature = "keepalive-anchors")] - #[used] static G3B: extern "C" fn(f64, *const u8) -> i32 = js_closure_exact_func_guard; + #[used] static G3B: extern "C" fn(f64, *const u8) -> u64 = js_closure_exact_func_guard; #[cfg(feature = "keepalive-anchors")] #[used] static G3C: unsafe extern "C" fn(f64, u32, u32, *const i8, usize, *const u8, *mut u64) -> u64 = js_object_own_method_cache_miss; #[cfg(feature = "keepalive-anchors")] diff --git a/crates/perry-runtime/src/typed_feedback/tests.rs b/crates/perry-runtime/src/typed_feedback/tests.rs index 4eb7f12bc4..2a2ba95428 100644 --- a/crates/perry-runtime/src/typed_feedback/tests.rs +++ b/crates/perry-runtime/src/typed_feedback/tests.rs @@ -1277,7 +1277,7 @@ fn representation_lowering_helpers_have_lto_keepalive_anchors() { ( guards, "static G3B", - "static G3B: extern \"C\" fn(f64, *const u8) -> i32", + "static G3B: extern \"C\" fn(f64, *const u8) -> u64", "js_closure_exact_func_guard", ), ( @@ -2334,7 +2334,10 @@ fn exact_closure_func_guard_is_safe_and_identity_exact() { let fn_ptr = test_direct_closure_ptr(); let closure = crate::closure::js_closure_alloc_singleton(fn_ptr); let closure_value = crate::value::js_nanbox_pointer(closure as i64); - assert_eq!(js_closure_exact_func_guard(closure_value, fn_ptr), 1); + assert_eq!( + js_closure_exact_func_guard(closure_value, fn_ptr), + closure as u64 + ); assert_eq!( js_closure_exact_func_guard(closure_value, test_direct_method_ptr()), 0 @@ -2356,8 +2359,8 @@ fn exact_closure_func_guard_is_safe_and_identity_exact() { #[test] fn own_method_cache_accepts_appends_and_rejects_live_method_mutation() { - const CLASS_ID: u32 = 0x7fff_fe75; - let object = crate::object::js_object_alloc(CLASS_ID, 0); + const TEST_CLASS_ID: u32 = 0x7fff_fe75; + let object = crate::object::js_object_alloc(TEST_CLASS_ID, 0); let method_key = crate::string::js_string_from_bytes(b"method".as_ptr(), 6); let extra_key = crate::string::js_string_from_bytes(b"state".as_ptr(), 5); let spilled_key = crate::string::js_string_from_bytes(b"spilled".as_ptr(), 7); @@ -2371,7 +2374,7 @@ fn own_method_cache_accepts_appends_and_rejects_live_method_mutation() { let first = unsafe { js_object_own_method_cache_miss( receiver, - CLASS_ID, + TEST_CLASS_ID, 0, b"method".as_ptr() as *const i8, 6, @@ -2387,7 +2390,7 @@ fn own_method_cache_accepts_appends_and_rejects_live_method_mutation() { let after_append = unsafe { js_object_own_method_cache_miss( receiver, - CLASS_ID, + TEST_CLASS_ID, 0, b"method".as_ptr() as *const i8, 6, @@ -2409,7 +2412,7 @@ fn own_method_cache_accepts_appends_and_rejects_live_method_mutation() { let after_spilled_append = unsafe { js_object_own_method_cache_miss( receiver, - CLASS_ID, + TEST_CLASS_ID, 0, b"method".as_ptr() as *const i8, 6, @@ -2429,7 +2432,7 @@ fn own_method_cache_accepts_appends_and_rejects_live_method_mutation() { let replaced = unsafe { js_object_own_method_cache_miss( receiver, - CLASS_ID, + TEST_CLASS_ID, 0, b"method".as_ptr() as *const i8, 6, @@ -2445,7 +2448,7 @@ fn own_method_cache_accepts_appends_and_rejects_live_method_mutation() { let deleted = unsafe { js_object_own_method_cache_miss( receiver, - CLASS_ID, + TEST_CLASS_ID, 0, b"method".as_ptr() as *const i8, 6, diff --git a/crates/perry/tests/issue_8775_imported_object_specialization.rs b/crates/perry/tests/issue_8775_imported_object_specialization.rs index f5a76c6e10..adecb11acd 100644 --- a/crates/perry/tests/issue_8775_imported_object_specialization.rs +++ b/crates/perry/tests/issue_8775_imported_object_specialization.rs @@ -219,8 +219,8 @@ fn stable_imported_object_methods_use_guarded_direct_closure_bodies() { } assert!(main_ir.contains("call double @js_native_call_method_by_id")); assert!(runner_ir.contains("call double @js_native_call_method_by_id")); - assert!(main_ir.contains("call i32 @js_closure_exact_func_guard")); - assert!(runner_ir.contains("call i32 @js_closure_exact_func_guard")); + assert!(main_ir.contains("call i64 @js_closure_exact_func_guard")); + assert!(runner_ir.contains("call i64 @js_closure_exact_func_guard")); assert!(main_ir.contains("call i64 @js_object_own_method_cache_miss")); assert!(runner_ir.contains("call i64 @js_object_own_method_cache_miss")); assert!(!main_ir.contains("call i32 @js_typed_feedback_closure_direct_call_guard")); diff --git a/scripts/gc_root_dominance_check.py b/scripts/gc_root_dominance_check.py index 90ba4c3bc5..8c52b5c151 100755 --- a/scripts/gc_root_dominance_check.py +++ b/scripts/gc_root_dominance_check.py @@ -475,6 +475,7 @@ def build_cfg(f): # inline-cache guards: pure reads "js_typed_feedback_closure_direct_call_guard", "js_closure_exact_func_guard", "js_object_own_method_cache_miss", + "js_packed_arraylike_loop_revalidate_live", # ctor identity selection "js_ctor_return_override", "llvm.lifetime.start.p0", "llvm.lifetime.end.p0", diff --git a/scripts/shape_descriptor_census_baseline.json b/scripts/shape_descriptor_census_baseline.json index 97901c90a0..27adb5bbcd 100644 --- a/scripts/shape_descriptor_census_baseline.json +++ b/scripts/shape_descriptor_census_baseline.json @@ -15,7 +15,7 @@ "crates/perry-codegen/src/expr/proxy_reflect.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 2, "crates/perry-codegen/src/lower_call/new.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, "crates/perry-codegen/src/lower_call/new_alloc.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple);": 1, - "crates/perry-codegen/src/lower_call/property_get/imported_object.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, + "crates/perry-codegen/src/lower_call/property_get/imported_object.rs|let header_skip = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, "crates/perry-codegen/src/lower_call/scalar_method.rs|let header_skip = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, "crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs|8 + crate::target_layout::object_header_size_bytes( ) + 8 * slots;": 1, "crates/perry-codegen/src/stmt/loops.rs|let object_header_size = crate::target_layout::object_header_size_bytes(ctx.target_triple);": 1, From 6a87729713f281edd42598d407171dbf019343c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 22:44:30 +0200 Subject: [PATCH 4/4] fix(codegen): keep the argument-shape route guard when the barrier kill is bypassed #8833 widened the `$pshape_args` route in three directions at once and removed the runtime class+ShapeId guard at the same time. Individually each widening is arguable; composed, they left an unguarded fixed-offset read of an object the compiler itself had recorded as published to an alias it cannot see. Reproduced as a codegen ratchet: a module with an unattributable `Object.defineProperty`, a callee that publishes its parameter after its licensed read, and two route sites on the same caller local emitted two unguarded direct calls into the clone (2 clone calls, 0 guard blocks). Two changes restore the invariant the PR's own doc comments assert: - The route-only fact is collected with rule 5's module-wide shape-barrier kill bypassed, so it must never license guard-free field access. Its route now keeps the runtime guard plus the generic fallback. Guard elision is retained only where the caller holds the broad `Ptr` representation fact, which was proven in a barrier-free module under full containment and where the guard is therefore tautological. - Route admission requires the clone to preserve containment for the parameter's whole lifetime. `PrefixContainedParamUse` proves a temporal property ("the reads happen before the publication"), but the fact map that carries a caller-side route is keyed by local id and is flow-insensitive, so a fact kept past a publishing call is consulted again at every later route site. The `require_post_call_containment` knob, whose only other mode was unsound, is deleted rather than left selectable. Also refreshes the `local_binding_type_audit` allowlist: #8833 hoisted `unique_global` out of `codegen/mod.rs`, moving the attribution of an unchanged `module_local_types` read to `compile_module`. The measured `perform-ecs` and Wolf routes are unaffected (fresh contained locals in barrier-free modules); the #8774 slice carried no speed claim. --- .../8833-argument-route-guard-invariant.md | 32 ++++++++ .../src/codegen/argument_shape_clone_tests.rs | 75 +++++++++++++++++-- .../perry-codegen/src/collectors/hir_facts.rs | 6 +- .../src/collectors/proven_args.rs | 17 +++-- .../perry-codegen/src/collectors/ptr_shape.rs | 8 +- .../src/collectors/ptr_shape_entry.rs | 8 +- .../src/collectors/ptr_shape_report.rs | 11 +-- crates/perry-codegen/src/expr/mod.rs | 25 +++++-- scripts/local_binding_type_allowlist.json | 2 +- 9 files changed, 154 insertions(+), 30 deletions(-) create mode 100644 changelog.d/8833-argument-route-guard-invariant.md diff --git a/changelog.d/8833-argument-route-guard-invariant.md b/changelog.d/8833-argument-route-guard-invariant.md new file mode 100644 index 0000000000..a0518796c6 --- /dev/null +++ b/changelog.d/8833-argument-route-guard-invariant.md @@ -0,0 +1,32 @@ +Argument-shape clone routes keep their runtime class+ShapeId guard whenever the caller's proof came +from the barrier-bypassing route-only pass, and a clone that publishes its parameter no longer keeps +a caller-side containment fact at all. + +The route-only proof deliberately bypasses rule 5's module-wide §5.2 shape-barrier kill +(`collectors/ptr_shape.rs`), which is the belt-and-braces backstop against blind spots in the +containment walk. Pairing that bypass with an elided entry guard, and simultaneously dropping the +requirement that the callee preserve containment, removed every net that could observe a reshaped +argument: a module carrying an unattributable `Object.defineProperty`/`delete`/`Proxy` site, a +method that publishes its parameter after its licensed read, and two call sites on the same caller +local produced two unguarded direct calls into `…$pshape_args`, the second reading declared fields +at fixed offsets from an object the compiler itself had recorded as published to an alias it cannot +see. + +`PrefixContainedParamUse` proves a temporal property — the licensed reads happen *before* the body +publishes the parameter — but the fact map that carries a caller-side route is keyed by local id and +is therefore flow-insensitive, so a fact kept past a publishing call is consulted again at every +later route site for that local. A per-local map cannot express "before", so route admission now +requires the clone to preserve containment for the parameter's whole lifetime, and the +`require_post_call_containment` knob whose only other mode was unsound is deleted rather than left +selectable. + +Guard elision is retained for exactly the case that justifies it: a caller holding the broad +`Ptr` representation fact, which by construction was proven in a barrier-free module under +full containment, where the caller is already licensed to read the same object's declared fields at +fixed offsets and the guard is tautological. The measured `perform-ecs` and Wolf routes are +unaffected — they are all fresh contained locals in barrier-free modules — and the #8774 slice +carried no speed claim to begin with. + +Pinned by `published_argument_in_a_barrier_module_never_reaches_an_unguarded_clone`, which asserts +its own subject is live (the clone must still be emitted) and fails with "2 clone calls, 0 guard +blocks" against the pre-fix code. diff --git a/crates/perry-codegen/src/codegen/argument_shape_clone_tests.rs b/crates/perry-codegen/src/codegen/argument_shape_clone_tests.rs index bffbfe61ba..d2bd3ad63f 100644 --- a/crates/perry-codegen/src/codegen/argument_shape_clone_tests.rs +++ b/crates/perry-codegen/src/codegen/argument_shape_clone_tests.rs @@ -311,6 +311,15 @@ fn define_property(target: Expr) -> Stmt { )) } +/// An unrelated §5.2 barrier does not suppress the route, but it DOES make the +/// runtime guard load-bearing. +/// +/// The route-only proof reaches this module by bypassing rule 5's module-wide +/// barrier kill. That kill is the belt-and-braces backstop against blind spots +/// in the containment walk (`ptr_shape.rs` rule 5), so with it bypassed the +/// entry guard is the only thing left that can observe a reshaped argument. +/// Eliding it here would leave the clone's fixed-offset reads with no check at +/// all in exactly the modules whose barriers the analysis refuses to attribute. #[test] fn unrelated_module_shape_barrier_keeps_guarded_argument_route() { let mut module = fixture(); @@ -324,9 +333,10 @@ fn unrelated_module_shape_barrier_keeps_guarded_argument_route() { "an unrelated barrier must not suppress the exact guarded route:\n{ir}" ); assert!( - !ir.contains("pshape_arg.fallback") + ir.contains("pshape_arg.fallback") && ir.contains("call double @perry_method_argument_shape_clone_ts__Registry__read("), - "an unrelated barrier must not reintroduce a redundant argument guard:\n{ir}" + "a route that bypassed the module-wide barrier kill must keep its \ + runtime guard and its generic fallback:\n{ir}" ); } @@ -344,8 +354,18 @@ fn barrier_targeting_argument_stays_on_generic_route() { ); } +/// A clone that publishes its parameter gets NO caller-side route. +/// +/// `PrefixContainedParamUse` proves a temporal property — the licensed field +/// reads happen before the body's first bare use of the parameter. The fact +/// map that would carry a caller-side route is keyed by local id and is +/// therefore flow-INSENSITIVE: a fact kept past a publishing call is consulted +/// again at every later route site for the same local, including sites that +/// run once the alias exists. A per-local map cannot express "before", so the +/// only sound reading is that no caller-side containment fact survives such a +/// call at all. #[test] -fn field_read_before_terminal_publication_gets_only_the_guarded_route() { +fn publishing_clone_gets_no_caller_side_route() { let mut module = fixture(); let param_id = module.classes[1].methods[0].params[0].id; module.classes[1].methods[0] @@ -357,8 +377,9 @@ fn field_read_before_terminal_publication_gets_only_the_guarded_route() { .expect("LLVM IR is UTF-8"); let clone_name = "perry_method_argument_shape_clone_ts__Registry__read$pshape_args"; assert!( - ir.contains(&format!("call double @{clone_name}(")), - "a direct read performed before publication should use the guarded clone:\n{ir}" + !ir.contains(&format!("call double @{clone_name}(")), + "a clone that publishes its parameter must not be routed from a \ + caller-side containment fact:\n{ir}" ); let entries = session.entries(); assert!( @@ -371,6 +392,50 @@ fn field_read_before_terminal_publication_gets_only_the_guarded_route() { ); } +/// #8833 regression: the three widenings must not compose into an unguarded +/// fixed-offset read of a published object. +/// +/// Fixture: a §5.2 barrier the analysis cannot attribute, a callee that +/// publishes its parameter after its licensed read, and TWO route sites on the +/// same caller local — so the second one executes after the alias exists. Every +/// safety net that could catch a reshape here had been removed at once: rule +/// 5's module kill (bypassed by the route-only proof), the caller's post-call +/// containment requirement, and the runtime class+ShapeId guard. +#[test] +fn published_argument_in_a_barrier_module_never_reaches_an_unguarded_clone() { + let mut module = fixture(); + let param_id = module.classes[1].methods[0].params[0].id; + // The callee reads the declared field, then publishes the parameter. + module.classes[1].methods[0] + .body + .push(Stmt::Return(Some(Expr::LocalGet(param_id)))); + // A module-wide §5.2 barrier whose target the containment walk cannot + // attribute to any tracked local. + module.init.insert(0, define_property(Expr::Object(vec![]))); + // A second route site on the same local, after the first published it. + let second_call = module.init.last().expect("fixture call").clone(); + module.init.push(second_call); + + let ir = String::from_utf8(compile_module(&module, opts()).expect("module compiles")) + .expect("LLVM IR is UTF-8"); + let clone_name = "perry_method_argument_shape_clone_ts__Registry__read$pshape_args"; + // Subject-liveness: the argument-clone machinery must actually be engaged + // by this fixture, or the assertion below would pass for the wrong reason. + assert!( + ir.contains(&format!("@{clone_name}(")), + "fixture must still emit the argument clone, or this test is vacuous:\n{ir}" + ); + let unguarded_calls = ir.matches(&format!("call double @{clone_name}(")).count(); + let guard_blocks = ir.matches("pshape_arg.fallback").count(); + + assert!( + unguarded_calls == 0 || guard_blocks > 0, + "a published argument in a barrier-carrying module reached the \ + argument clone with no runtime class+ShapeId guard \ + ({unguarded_calls} clone calls, {guard_blocks} guard blocks):\n{ir}" + ); +} + #[test] fn field_read_after_publication_does_not_get_a_clone() { let mut module = fixture(); diff --git a/crates/perry-codegen/src/collectors/hir_facts.rs b/crates/perry-codegen/src/collectors/hir_facts.rs index 8f5688aeb6..f8ea18aea7 100644 --- a/crates/perry-codegen/src/collectors/hir_facts.rs +++ b/crates/perry-codegen/src/collectors/hir_facts.rs @@ -183,8 +183,10 @@ pub(crate) struct ShapeStabilityFacts { /// (`lower_call/property_get/dynamic_dispatch.rs`). pub shape_proven_ptr_locals: HashMap, /// Fresh-object containment facts consumed exclusively by guarded - /// `$pshape_args` routes. This map remains available in modules with - /// shape barriers, but it must never license guard-free field access. + /// argument-shape clone routes. This map remains available in modules + /// with shape barriers, so it must never license guard-free field access: + /// its consumer (`FnCtx::ptr_shape_argument_route_fact`) pairs it with a + /// mandatory runtime class+ShapeId guard. pub guarded_argument_route_locals: HashMap, /// Representation-selection Phase 4a.3: function-locals proven to satisfy /// the `Ptr` invariants (raw-f64-or-hole slots, never-shrinking diff --git a/crates/perry-codegen/src/collectors/proven_args.rs b/crates/perry-codegen/src/collectors/proven_args.rs index 251e7d3e73..111b5cfad1 100644 --- a/crates/perry-codegen/src/collectors/proven_args.rs +++ b/crates/perry-codegen/src/collectors/proven_args.rs @@ -146,8 +146,17 @@ fn class_fields_cover( } /// Check the caller-side provenance and alias terms for one guarded route. -/// `require_post_call_containment` additionally rejects clones that publish the -/// parameter after their specialized prefix. +/// +/// A route may only be admitted when the clone preserves containment for the +/// parameter's whole lifetime. That is not a stylistic preference: the fact +/// map this feeds is keyed by local id and therefore flow-INSENSITIVE, so a +/// fact kept past a publishing call is consulted again at every later route +/// site for the same local — including sites that execute after the alias +/// exists. `PrefixContainedParamUse` proves a temporal property ("the reads +/// happen before the publication"), which a per-local map cannot express, so +/// the only sound reading of a publishing clone is that no caller-side +/// containment fact survives it at all. +#[allow(clippy::too_many_arguments)] pub(super) fn route_preserves_argument_containment( module_dispatch: &ModuleDispatchFacts, candidates: &HashMap, @@ -158,7 +167,6 @@ pub(super) fn route_preserves_argument_containment( param_index: usize, arg: &Expr, call_args: &[Expr], - require_post_call_containment: bool, ) -> bool { let Expr::LocalGet(id) = arg else { return false; @@ -188,8 +196,7 @@ pub(super) fn route_preserves_argument_containment( let Some((expected, preserves_containment)) = route else { return false; }; - (!require_post_call_containment || preserves_containment) - && candidates.get(root).is_some_and(|got| got == expected) + preserves_containment && candidates.get(root).is_some_and(|got| got == expected) } /// Direct declared-field reads are safe until the first bare use publishes the diff --git a/crates/perry-codegen/src/collectors/ptr_shape.rs b/crates/perry-codegen/src/collectors/ptr_shape.rs index f8992c85d1..863526517b 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape.rs @@ -1193,7 +1193,6 @@ impl<'a> UseWalk<'a> { param_index, a, args, - self.purpose == CollectionPurpose::UnguardedRepresentation, ) { continue; @@ -1208,9 +1207,9 @@ impl<'a> UseWalk<'a> { // the receiver class after this analysis (notably for // `this.m(fresh)`). Preserve the fresh argument only when // all emitted clones with this method name and position - // agree on its class. The fact is invisible to ordinary - // field/method lowering and is consumed only beside the - // live class+ShapeId guard. + // agree on its class AND preserve containment. The fact is + // invisible to ordinary field/method lowering and is + // consumed only beside the live class+ShapeId guard. if self.purpose == CollectionPurpose::GuardedArgumentRoute && matches!(object.as_ref(), Expr::This | Expr::LocalGet(_)) { @@ -1226,7 +1225,6 @@ impl<'a> UseWalk<'a> { param_index, arg, args, - false, ) { continue; } diff --git a/crates/perry-codegen/src/collectors/ptr_shape_entry.rs b/crates/perry-codegen/src/collectors/ptr_shape_entry.rs index a323c43341..188b2c844f 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_entry.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_entry.rs @@ -83,7 +83,13 @@ pub(crate) fn collect_shape_proven_ptr_locals_and_element_fields( ) } -/// Containment facts consumable only beside a `$pshape_args` live guard. +/// Containment facts consumable only beside a live argument-shape guard. +/// +/// This differs from the representation proof above in exactly one respect: +/// rule 5's module-wide barrier kill is bypassed. Rules 1-4 still bound every +/// path to the object, and the emitted route still revalidates the live class +/// and ShapeId, so the belt-and-braces module kill is redundant here — but the +/// runtime guard is NOT, and the consumer must keep it. #[allow(clippy::too_many_arguments)] pub(crate) fn collect_guarded_argument_route_locals( stmts: &[Stmt], diff --git a/crates/perry-codegen/src/collectors/ptr_shape_report.rs b/crates/perry-codegen/src/collectors/ptr_shape_report.rs index b31f55e600..891b54ee1c 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_report.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_report.rs @@ -219,11 +219,12 @@ pub(super) const ESC_CLOSURE_CAPTURE: ShapeDenial = ShapeDenial { pub(super) const ESC_CALL_ARGUMENT: ShapeDenial = ShapeDenial { rule: RULE2, - reason: "passed to a call that does not preserve whole-lifetime \ - containment. A guarded `$pshape_args` route may still specialize \ - field reads performed before the callee publishes the argument, \ - but the caller cannot retain an unguarded `Ptr` fact after \ - that call.", + reason: "passed as a call argument that does not preserve whole-lifetime \ + containment. An audited argument-shape clone can specialize a \ + callee that never publishes the parameter, but every other \ + argument position — and every call whose callee is not such a \ + clone — disqualifies, because no shape fact at a call site \ + becomes a fact about an arbitrary callee's parameter.", tier: Tier::CompilerLimitation, issue: Some("#8774 (guarded argument clone-and-route)"), }; diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 108e2052a0..2a78e17793 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -2210,12 +2210,25 @@ impl<'a> FnCtx<'a> { .or_else(|| self.native_facts.shape_proven_ptr_local(id)) } - /// Caller-side containment proof used to admit a `$pshape_args` route. + /// Caller-side containment proof used to admit an argument-shape clone + /// route, paired with whether that route must still emit its runtime + /// class+ShapeId guard. /// /// This deliberately ignores the raw-pointer representation context gate: /// the caller keeps a tagged value, the route rechecks its live class and /// shape, and the clone binds its own tagged shadow slot. Only the proof /// that no external alias can reshape the argument is consumed here. + /// + /// The guard may be elided in exactly one case: the caller already holds + /// the BROAD `Ptr` representation fact, which by construction was + /// proven in a barrier-free module (rule 5) with full containment (rules + /// 1-4). There the caller is itself licensed to read this object's + /// declared fields at fixed offsets without a guard, so the clone's reads + /// add no exposure and the guard is tautological. + /// + /// The route-only fact is weaker — it is collected with rule 5's + /// module-wide barrier kill BYPASSED — so it must never license guard-free + /// field access, and its route keeps the guard plus the generic fallback. pub(crate) fn ptr_shape_argument_route_fact( &self, e: &perry_hir::Expr, @@ -2224,21 +2237,21 @@ impl<'a> FnCtx<'a> { // Ordinary native facts are containment proofs. A selected clone // parameter inherits the class fact from its caller's guard, but // forwarded clone parameters retain an explicit guard/fallback at - // the next route. A fresh local's provenance+containment proof is - // already stronger than re-reading the same runtime header here. + // the next route because their fact originates at a dynamic + // caller boundary. perry_hir::Expr::LocalGet(id) => self .proven_shape_params .get(id) .map(|fact| (fact, true)) .or_else(|| { self.native_facts - .guarded_argument_route_local(*id) + .shape_proven_ptr_local(*id) .map(|fact| (fact, false)) }) .or_else(|| { self.native_facts - .shape_proven_ptr_local(*id) - .map(|fact| (fact, false)) + .guarded_argument_route_local(*id) + .map(|fact| (fact, true)) }), // `proven_this` may come from a runtime receiver guard rather than // containment, so it cannot justify an argument clone route. diff --git a/scripts/local_binding_type_allowlist.json b/scripts/local_binding_type_allowlist.json index 650d41211b..3626bbbac1 100644 --- a/scripts/local_binding_type_allowlist.json +++ b/scripts/local_binding_type_allowlist.json @@ -19,7 +19,7 @@ }, { "path": "crates/perry-codegen/src/codegen/mod.rs", - "function": "unique_global", + "function": "compile_module", "access": "raw:module_local_types", "count": 1, "classification": "metadata-only",