diff --git a/changelog.d/8787-argument-shape-clones.md b/changelog.d/8787-argument-shape-clones.md new file mode 100644 index 0000000000..979da5f7b4 --- /dev/null +++ b/changelog.d/8787-argument-shape-clones.md @@ -0,0 +1,6 @@ +Guarded direct method calls now propagate exact class and shape facts into +eligible object arguments, including unannotated JavaScript parameters with a +unique declared-field signature. The internal tagged-ABI clones use direct +field offsets while exact runtime guards, shadow roots, and the ordinary method +fallback preserve behavior for subclasses, proxies, mutated shapes, and other +dynamic values. diff --git a/changelog.d/8792-captured-closure-cache-hints.md b/changelog.d/8792-captured-closure-cache-hints.md new file mode 100644 index 0000000000..31c8dcb383 --- /dev/null +++ b/changelog.d/8792-captured-closure-cache-hints.md @@ -0,0 +1 @@ +Captured-closure singleton reuse now fingerprints each exact capture tuple and maintains a direct-mapped, collision-safe hint into its bounded LRU. Hits no longer move vector entries, hash or hint collisions still require bit-exact capture equality, and copying GC recomputes fingerprints while discarding stale hints after rewriting pointer-bearing captures. diff --git a/changelog.d/8793-static-method-object-literals.md b/changelog.d/8793-static-method-object-literals.md new file mode 100644 index 0000000000..b0551eedbf --- /dev/null +++ b/changelog.d/8793-static-method-object-literals.md @@ -0,0 +1 @@ +Static-key object literals containing methods now use the ordinary final-shape object lowering when every value is independent of the hidden home object. This removes the synthetic builder closure and property-by-property mutation while preserving evaluation order, dynamic `this`, capture semantics, and inferred method names; `super`, computed keys, spreads, accessors, prototype setters, and other source-ordered forms retain the fail-closed builder path. diff --git a/crates/perry-codegen/src/codegen/argument_shape_clone_tests.rs b/crates/perry-codegen/src/codegen/argument_shape_clone_tests.rs new file mode 100644 index 0000000000..27fbf9ff31 --- /dev/null +++ b/crates/perry-codegen/src/codegen/argument_shape_clone_tests.rs @@ -0,0 +1,300 @@ +//! #8774 exact-shape ordinary-argument clone ratchets. + +use crate::{compile_module, AppMetadata, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{Class, ClassField, Expr, Function, Module, Param, Stmt}; + +fn opts() -> CompileOptions { + CompileOptions { + emit_ir_only: true, + is_entry_module: true, + output_type: "executable".to_string(), + app_metadata: AppMetadata::default(), + ..CompileOptions::default() + } +} + +fn param(id: u32, name: &str, ty: Type) -> Param { + Param { + id, + name: name.to_string(), + ty, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + } +} + +fn function(id: u32, name: &str, params: Vec, body: Vec) -> Function { + Function { + id, + name: name.to_string(), + type_params: Vec::new(), + params, + return_type: Type::Void, + body, + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } +} + +fn class(id: u32, name: &str, fields: Vec<&str>, methods: Vec) -> Class { + Class { + id, + name: name.to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: fields + .into_iter() + .map(|name| ClassField { + name: name.to_string(), + key_expr: None, + ty: Type::Any, + init: None, + is_private: false, + is_readonly: false, + decorators: Vec::new(), + }) + .collect(), + constructor: None, + methods, + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + computed_members: Vec::new(), + decorators: Vec::new(), + is_exported: false, + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + aliases: Vec::new(), + } +} + +fn field_get(local: u32, property: &str) -> Expr { + Expr::PropertyGet { + object: Box::new(Expr::LocalGet(local)), + property: property.to_string(), + byte_offset: 0, + } +} + +fn fixture() -> Module { + let entity_param = 20; + let read = function( + 200, + "read", + vec![param( + entity_param, + "entity", + Type::Named("Entity".to_string()), + )], + vec![Stmt::Expr(field_get(entity_param, "id"))], + ); + let mut module = Module::new("argument_shape_clone.ts"); + module.classes.push(class(1, "Entity", vec!["id"], vec![])); + module + .classes + .push(class(2, "Registry", vec![], vec![read])); + module.init.extend([ + Stmt::Let { + id: 10, + name: "registry".to_string(), + ty: Type::Named("Registry".to_string()), + mutable: false, + init: Some(Expr::New { + class_name: "Registry".to_string(), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }), + }, + Stmt::Let { + id: 11, + name: "entity".to_string(), + ty: Type::Named("Entity".to_string()), + mutable: false, + init: Some(Expr::New { + class_name: "Entity".to_string(), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }), + }, + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(10)), + property: "read".to_string(), + byte_offset: 0, + }), + args: vec![Expr::LocalGet(11)], + type_args: Vec::new(), + byte_offset: 0, + }), + ]); + module +} + +fn function_ir<'a>(ir: &'a str, marker: &str) -> &'a str { + let start = ir + .match_indices("define ") + .find(|(index, _)| { + let end = ir[*index..] + .find('\n') + .map(|offset| index + offset) + .unwrap_or(ir.len()); + ir[*index..end].contains(marker) + }) + .map(|(index, _)| index) + .unwrap_or_else(|| panic!("missing function {marker}:\n{ir}")); + let end = ir[start..] + .find("\n}") + .map(|offset| start + offset) + .expect("function terminator"); + &ir[start..end] +} + +#[test] +fn guarded_call_routes_to_shadow_rooted_direct_field_clone() { + // Native statepoint roots are the host default. Pin the shadow-stack + // lowering because this assertion specifically ratchets the portable + // tagged-slot fallback required by the clone ABI. + let _shadow = crate::codegen::helpers::NativeRootsPin::shadow(); + let ir = String::from_utf8(compile_module(&fixture(), opts()).expect("module compiles")) + .expect("LLVM IR is UTF-8"); + let clone_name = "perry_method_argument_shape_clone_ts__Registry__read$pshape_args"; + let clone = function_ir(&ir, &format!("@{clone_name}(")); + let generic = function_ir( + &ir, + "@perry_method_argument_shape_clone_ts__Registry__read(", + ); + + assert!( + ir.contains(&format!("call double @{clone_name}(")), + "the guarded call site must route to the argument clone:\n{ir}" + ); + assert!( + 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}" + ); + assert!( + clone.contains("@js_shadow_slot_bind(") + && clone.find("@js_shadow_slot_bind(") + < clone + .find("getelementptr double") + .or_else(|| clone.find("inttoptr i64")), + "the tagged parameter slot must be bound before fixed-offset access:\n{clone}" + ); + assert!( + clone.contains("inttoptr i64") && clone.contains("getelementptr double"), + "the clone must use direct declared-field addressing:\n{clone}" + ); + assert!( + !clone.contains("js_typed_feedback_class_field_get_guard") + && !clone.contains("shape_descriptor_by_id"), + "the clone fast body must not rebuild the field IC diamond:\n{clone}" + ); + assert!( + generic.contains("js_typed_feedback_class_field_get_guard") + || generic.contains("js_object_get_field"), + "the generic fallback must retain guarded field semantics:\n{generic}" + ); +} + +#[test] +fn routed_argument_is_a_contained_ptr_shape_win_in_the_opt_report() { + let session = crate::opt_report::test_support::Session::start(); + compile_module(&fixture(), opts()).expect("module compiles"); + let entries = session.entries(); + let entity_entries: Vec<_> = entries + .iter() + .filter(|entry| entry.name == "entity" && entry.local_id == Some(11)) + .collect(); + + assert!( + entity_entries + .iter() + .any(|entry| entry.outcome == crate::opt_report::Outcome::Selected), + "the guarded argument route must preserve the caller's Ptr fact: {entries:#?}" + ); + assert!( + entity_entries.iter().all(|entry| { + entry.outcome != crate::opt_report::Outcome::Denied + || !entry + .reason + .as_deref() + .unwrap_or("") + .contains("passed as a call argument") + }), + "the retired call-argument denial must not survive a selected clone route: {entries:#?}" + ); +} + +#[test] +fn unannotated_parameter_uses_the_runtime_validated_class_overlay() { + let mut module = fixture(); + module.classes[1].methods[0].params[0].ty = Type::Any; + let ir = String::from_utf8(compile_module(&module, opts()).expect("module compiles")) + .expect("LLVM IR is UTF-8"); + let clone = function_ir(&ir, "Registry__read$pshape_args("); + assert!( + clone.contains("getelementptr double") && clone.contains("inttoptr i64"), + "the exact runtime guard must recover the class layout for an unannotated parameter:\n{clone}" + ); + assert!( + !clone.contains("js_typed_feedback_class_field_get_guard") + && !clone.contains("shape_descriptor_by_id"), + "the unannotated clone must not rebuild the field IC diamond:\n{clone}" + ); +} + +#[test] +fn ambiguous_unannotated_field_signature_stays_generic() { + let mut module = fixture(); + module.classes[1].methods[0].params[0].ty = Type::Any; + module + .classes + .push(class(3, "OtherEntity", vec!["id"], vec![])); + 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 unannotated field signature matching multiple classes must not nominate either:\n{ir}" + ); +} + +#[test] +fn aliased_or_reassigned_parameter_does_not_get_a_clone() { + let mut module = fixture(); + let method = &mut module.classes[1].methods[0]; + method.body.insert( + 0, + Stmt::Expr(Expr::LocalSet( + method.params[0].id, + Box::new(Expr::Undefined), + )), + ); + 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"), + "a reassigned parameter must keep only generic semantics:\n{ir}" + ); +} diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index 11e9f4f7d7..e37593235b 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -26,6 +26,9 @@ use super::method::{ compile_typed_string_method, }; use super::native_namespace_exports::emit_native_namespace_reexport_getters; +use super::ordinary_method_artifacts::{ + compile_ordinary_method_artifacts, OrdinaryMethodArtifactsCtx, +}; use super::spec_function_length; use super::string_pool::emit_string_pool; use super::typed_abi::TypedFunctionTrampolineKind; @@ -323,52 +326,19 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { nonnegative_index_params, )?; } - compile_method( - llmod, - class, - method, - func_names, - strings, - class_table, - method_names, - module_globals, - module_global_types, - opts.import_function_prefixes, - enum_table, - static_field_globals, - class_ids, - func_signatures, - func_synthetic_arguments, - module_boxed_vars, - closure_rest_params, - cross_module, - typed_public_trampoline, - cross_module - .typed_f64_receiver_methods - .contains_key(&(class.name.clone(), method.name.clone())), - None, - None, - false, - false, - false, - ) - .with_context(|| format!("lowering method '{}::{}'", class.name, method.name))?; - if cross_module - .guarded_undefined_method_params - .contains_key(&(class.name.clone(), method.name.clone())) - { - compile_method( + compile_ordinary_method_artifacts( + OrdinaryMethodArtifactsCtx { llmod, class, method, func_names, strings, - class_table, - method_names, + classes: class_table, + methods: method_names, module_globals, module_global_types, - opts.import_function_prefixes, - enum_table, + import_function_prefixes: opts.import_function_prefixes, + enums: enum_table, static_field_globals, class_ids, func_signatures, @@ -376,148 +346,9 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { module_boxed_vars, closure_rest_params, cross_module, - None, - false, - None, - None, - false, - false, - true, - ) - .with_context(|| { - format!( - "lowering exact-undefined clone of method '{}::{}'", - class.name, method.name - ) - })?; - } - // Representation-selection Phase 5a: the additive `internal` - // proven-`this` clone. Same HIR, same ABI, same shadow-bound - // tagged-at-rest receiver slot — only `this.field` lowering - // differs (bare fixed-offset access instead of the per-access - // guard diamond). Reached ONLY from the two call sites that - // already prove the receiver's exact shape; never registered into - // a runtime vtable. - if let Some(fact) = cross_module - .pshape_methods - .get(&(class.name.clone(), method.name.clone())) - { - compile_method( - llmod, - class, - method, - func_names, - strings, - class_table, - method_names, - module_globals, - module_global_types, - opts.import_function_prefixes, - enum_table, - static_field_globals, - class_ids, - func_signatures, - func_synthetic_arguments, - module_boxed_vars, - closure_rest_params, - cross_module, - None, - false, - Some(fact.clone()), - None, - false, - false, - false, - ) - .with_context(|| { - format!( - "lowering proven-`this` clone of method '{}::{}'", - class.name, method.name - ) - })?; - if cross_module - .guarded_undefined_method_params - .contains_key(&(class.name.clone(), method.name.clone())) - { - compile_method( - llmod, - class, - method, - func_names, - strings, - class_table, - method_names, - module_globals, - module_global_types, - opts.import_function_prefixes, - enum_table, - static_field_globals, - class_ids, - func_signatures, - func_synthetic_arguments, - module_boxed_vars, - closure_rest_params, - cross_module, - None, - false, - Some(fact.clone()), - None, - false, - false, - true, - ) - .with_context(|| { - format!( - "lowering proven-`this` exact-undefined clone of method '{}::{}'", - class.name, method.name - ) - })?; - } - - // #8607: a second, stricter clone for the Phase 3b - // provenance+containment route. Its synthetic immutable - // aliases keep stable array-valued fields in local slots, so - // existing local-array loop optimizations can see through - // repeated `this.field` uses. It is never selected by the - // guarded or dispatch-tower `$pshape` routes. - if let Some(cached_method) = - crate::collectors::ptr_array_cached_method(class, method) - { - compile_method( - llmod, - class, - &cached_method, - func_names, - strings, - class_table, - method_names, - module_globals, - module_global_types, - opts.import_function_prefixes, - enum_table, - static_field_globals, - class_ids, - func_signatures, - func_synthetic_arguments, - module_boxed_vars, - closure_rest_params, - cross_module, - None, - false, - Some(fact.clone()), - None, - false, - true, - false, - ) - .with_context(|| { - format!( - "lowering contained-receiver array-cache clone of method '{}::{}'", - class.name, method.name - ) - })?; - } - } + }, + typed_public_trampoline, + )?; } for member in class .computed_members @@ -550,6 +381,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { false, false, false, + false, ) .with_context(|| { format!( @@ -619,6 +451,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { false, false, false, + false, ) .with_context(|| format!("lowering getter '{}::{}'", class.name, prop))?; } @@ -676,6 +509,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { false, false, false, + false, ) .with_context(|| format!("lowering setter '{}::{}'", class.name, prop))?; } @@ -775,6 +609,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { false, false, false, + false, ) .with_context(|| format!("lowering constructor for '{}'", class.name))?; } diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 0cd51ad02f..6c5b4ca67c 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -1154,12 +1154,14 @@ pub(super) fn compile_closure( typed_i1_function_param_reps: &cross_module.typed_i1_function_param_reps, typed_f64_methods: &cross_module.typed_f64_methods, pshape_methods: &cross_module.pshape_methods, + pshape_arg_methods: &cross_module.pshape_arg_methods, nonnegative_index_methods: &cross_module.nonnegative_index_methods, trusted_array_param_handles: HashMap::new(), versioned_indexed_loop_facts: Vec::new(), stable_packed_loop_facts: Vec::new(), pshape_tower_routable: &cross_module.pshape_tower_routable, proven_this: None, + proven_shape_params: std::collections::HashMap::new(), typed_i32_methods: &cross_module.typed_i32_methods, typed_i1_methods: &cross_module.typed_i1_methods, typed_string_methods: &cross_module.typed_string_methods, diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 0012ed02c7..7fc4ac852c 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -954,12 +954,14 @@ pub(super) fn compile_module_entry( typed_i1_function_param_reps: &cross_module.typed_i1_function_param_reps, typed_f64_methods: &cross_module.typed_f64_methods, pshape_methods: &cross_module.pshape_methods, + pshape_arg_methods: &cross_module.pshape_arg_methods, nonnegative_index_methods: &cross_module.nonnegative_index_methods, trusted_array_param_handles: HashMap::new(), versioned_indexed_loop_facts: Vec::new(), stable_packed_loop_facts: Vec::new(), pshape_tower_routable: &cross_module.pshape_tower_routable, proven_this: None, + proven_shape_params: std::collections::HashMap::new(), typed_i32_methods: &cross_module.typed_i32_methods, typed_i1_methods: &cross_module.typed_i1_methods, typed_string_methods: &cross_module.typed_string_methods, @@ -1657,12 +1659,14 @@ pub(super) fn compile_module_entry( typed_i1_function_param_reps: &cross_module.typed_i1_function_param_reps, typed_f64_methods: &cross_module.typed_f64_methods, pshape_methods: &cross_module.pshape_methods, + pshape_arg_methods: &cross_module.pshape_arg_methods, nonnegative_index_methods: &cross_module.nonnegative_index_methods, trusted_array_param_handles: HashMap::new(), versioned_indexed_loop_facts: Vec::new(), stable_packed_loop_facts: Vec::new(), pshape_tower_routable: &cross_module.pshape_tower_routable, proven_this: None, + proven_shape_params: std::collections::HashMap::new(), typed_i32_methods: &cross_module.typed_i32_methods, typed_i1_methods: &cross_module.typed_i1_methods, typed_string_methods: &cross_module.typed_string_methods, diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index a265be8f14..0e59ec451f 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -1204,12 +1204,14 @@ pub(super) fn compile_function( typed_i1_function_param_reps: &cross_module.typed_i1_function_param_reps, typed_f64_methods: &cross_module.typed_f64_methods, pshape_methods: &cross_module.pshape_methods, + pshape_arg_methods: &cross_module.pshape_arg_methods, nonnegative_index_methods: &cross_module.nonnegative_index_methods, trusted_array_param_handles: HashMap::new(), versioned_indexed_loop_facts: Vec::new(), stable_packed_loop_facts: Vec::new(), pshape_tower_routable: &cross_module.pshape_tower_routable, proven_this: None, + proven_shape_params: std::collections::HashMap::new(), typed_i32_methods: &cross_module.typed_i32_methods, typed_i1_methods: &cross_module.typed_i1_methods, typed_string_methods: &cross_module.typed_string_methods, diff --git a/crates/perry-codegen/src/codegen/indexed_method_artifacts.rs b/crates/perry-codegen/src/codegen/indexed_method_artifacts.rs index cf0416d261..2f30812736 100644 --- a/crates/perry-codegen/src/codegen/indexed_method_artifacts.rs +++ b/crates/perry-codegen/src/codegen/indexed_method_artifacts.rs @@ -83,6 +83,7 @@ pub(super) fn compile_indexed_method_clones( false, false, false, + false, ) .with_context(|| { format!( @@ -122,6 +123,7 @@ pub(super) fn compile_indexed_method_clones( true, false, false, + false, ) .with_context(|| { format!( diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index cc9d116252..f5ad672265 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -56,6 +56,7 @@ pub(super) fn compile_method( fast_array_handle_clone: bool, ptr_array_cache_clone: bool, guarded_undefined_clone: bool, + pshape_arg_clone: bool, ) -> Result<()> { let public_llvm_name = methods .get(&(class.name.clone(), method.name.clone())) @@ -72,9 +73,16 @@ pub(super) fn compile_method( // lowerer. It never replaces the public symbol and never participates in // the typed-trampoline / generic-body split — those are emitted by the // primary (`proven_this: None`) invocation for this same method. - let is_pshape_clone = proven_this.is_some(); + let is_pshape_clone = proven_this.is_some() && !pshape_arg_clone; let is_index_clone = nonnegative_index_params.is_some(); - let guarded_undefined_param = (!is_index_clone && !ptr_array_cache_clone) + let pshape_arg_plan = pshape_arg_clone + .then(|| { + cross_module + .pshape_arg_methods + .get(&(class.name.clone(), method.name.clone())) + }) + .flatten(); + let guarded_undefined_param = (!is_index_clone && !ptr_array_cache_clone && !pshape_arg_clone) .then(|| { cross_module .guarded_undefined_method_params @@ -97,7 +105,12 @@ pub(super) fn compile_method( debug_assert!(!guarded_undefined_clone || guarded_undefined_param.is_some()); debug_assert!(!guarded_undefined_clone || !is_index_clone); debug_assert!(!guarded_undefined_clone || !ptr_array_cache_clone); - let family_name = if ptr_array_cache_clone { + debug_assert!(!pshape_arg_clone || pshape_arg_plan.is_some()); + debug_assert!(!pshape_arg_clone || !is_index_clone); + debug_assert!(!pshape_arg_clone || !ptr_array_cache_clone); + let family_name = if pshape_arg_clone { + crate::collectors::pshape_args_method_name(&public_llvm_name) + } else if ptr_array_cache_clone { crate::collectors::ptr_array_cache_method_name(&public_llvm_name) } else if is_pshape_clone { crate::collectors::pshape_method_name(&public_llvm_name) @@ -118,7 +131,7 @@ pub(super) fn compile_method( ) } else if guarded_undefined_param.is_some() { generic_method_body_name(&family_name) - } else if ptr_array_cache_clone || is_pshape_clone { + } else if ptr_array_cache_clone || is_pshape_clone || pshape_arg_clone { family_name.clone() } else if typed_public_trampoline.is_some() || force_generic_body { generic_method_body_name(&public_llvm_name) @@ -151,10 +164,11 @@ pub(super) fn compile_method( || typed_public_trampoline.is_some() || force_generic_body || guarded_undefined_param.is_some() + || pshape_arg_clone { lf.linkage = "internal".to_string(); } - super::helpers::apply_pshape_inline_policy(lf, method, is_pshape_clone); + super::helpers::apply_pshape_inline_policy(lf, method, is_pshape_clone || pshape_arg_clone); if is_index_clone { lf.pre_statepoint_inline = true; } @@ -309,6 +323,19 @@ pub(super) fn compile_method( if let Some(index) = guarded_undefined_param.filter(|_| guarded_undefined_clone) { guarded_param_proofs.insert(method.params[index].id, perry_hir::types::Type::Void); } + if let Some(plan) = pshape_arg_plan { + // Unlike a source annotation, this overlay is backed by the exact + // class+shape guard that exclusively routes into `$pshape_args`. + // Supplying it to property dispatch lets an unannotated (`Any`) JS + // parameter resolve the declared field before the Ptr overlay + // removes that field access's ordinary IC diamond. + guarded_param_proofs.extend(plan.args.iter().map(|arg| { + ( + arg.param_id, + perry_hir::types::Type::Named(arg.fact.class_name.clone()), + ) + })); + } let mut reassigned_locals = crate::collectors::reassigned_locals(&method.body); if let Some(index) = guarded_undefined_param.filter(|_| guarded_undefined_clone) { // Candidate discovery already rejected every user-authored write and @@ -507,6 +534,7 @@ pub(super) fn compile_method( typed_i1_function_param_reps: &cross_module.typed_i1_function_param_reps, typed_f64_methods: &cross_module.typed_f64_methods, pshape_methods: &cross_module.pshape_methods, + pshape_arg_methods: &cross_module.pshape_arg_methods, nonnegative_index_methods: &cross_module.nonnegative_index_methods, trusted_array_param_handles: fast_array_param_ids .iter() @@ -517,6 +545,14 @@ pub(super) fn compile_method( stable_packed_loop_facts: Vec::new(), pshape_tower_routable: &cross_module.pshape_tower_routable, proven_this, + proven_shape_params: pshape_arg_plan + .map(|plan| { + plan.args + .iter() + .map(|arg| (arg.param_id, arg.fact.clone())) + .collect() + }) + .unwrap_or_default(), typed_i32_methods: &cross_module.typed_i32_methods, typed_i1_methods: &cross_module.typed_i1_methods, typed_string_methods: &cross_module.typed_string_methods, @@ -1770,12 +1806,14 @@ pub(super) fn compile_static_method( typed_i1_function_param_reps: &cross_module.typed_i1_function_param_reps, typed_f64_methods: &cross_module.typed_f64_methods, pshape_methods: &cross_module.pshape_methods, + pshape_arg_methods: &cross_module.pshape_arg_methods, nonnegative_index_methods: &cross_module.nonnegative_index_methods, trusted_array_param_handles: HashMap::new(), versioned_indexed_loop_facts: Vec::new(), stable_packed_loop_facts: Vec::new(), pshape_tower_routable: &cross_module.pshape_tower_routable, proven_this: None, + proven_shape_params: std::collections::HashMap::new(), typed_i32_methods: &cross_module.typed_i32_methods, typed_i1_methods: &cross_module.typed_i1_methods, typed_string_methods: &cross_module.typed_string_methods, diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 7257ad8967..0f21557b32 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -192,9 +192,12 @@ mod hoisted_callback_method_tests; #[cfg(test)] mod index_method_clone_tests; mod indexed_method_artifacts; +mod ordinary_method_artifacts; // `pub(crate)` so `crate::linker` can read the inline-hot-small policy // (`inline_hot_small_enabled` / `inline_hot_small_hint_threshold`). #[cfg(test)] +mod argument_shape_clone_tests; +#[cfg(test)] mod clone_suffix_tests; #[cfg(test)] mod declared_string_add_tests; @@ -2016,11 +2019,65 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> && !nonnegative_index_methods.contains_key(key) }); guarded_undefined_method_candidates.sort_unstable_by(|left, right| left.cmp(right)); - let guarded_undefined_method_params = guarded_undefined_method_candidates - .into_iter() - .take(16) - .map(|(_, key, param_index)| (key, param_index)) + let guarded_undefined_method_params: std::collections::HashMap<(String, String), usize> = + guarded_undefined_method_candidates + .into_iter() + .take(16) + .map(|(_, key, param_index)| (key, param_index)) + .collect(); + // #8774: one non-combinatorial tagged-ABI clone per local method. Source + // annotations or a unique unannotated field signature only nominate a + // 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(); + let mut pshape_arg_methods = std::collections::HashMap::new(); + for class in &hir.classes { + for method in &class.methods { + let key = (class.name.clone(), method.name.clone()); + if typed_f64_methods.contains(&key) + || typed_i32_methods.contains(&key) + || typed_i1_methods.contains(&key) + || typed_string_methods.contains(&key) + || typed_f64_receiver_methods.contains_key(&key) + || nonnegative_index_methods.contains_key(&key) + || guarded_undefined_method_params.contains_key(&key) + { + continue; + } + let Some(mut plan) = crate::collectors::method_proven_shape_args( + method, + receiver_class_table, + &local_class_names, + &module_dispatch_facts, + ) 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); + } + } + } + module_dispatch_facts.install_argument_shape_routes(pshape_arg_methods.iter().map( + |(key, plan)| { + ( + key.clone(), + plan.args + .iter() + .map(|arg| (arg.param_index, arg.fact.class_name.clone())) + .collect(), + ) + }, + )); let mut compiler_private_async_i32_control_locals = std::collections::HashSet::new(); let mut compiler_private_async_i1_control_locals = std::collections::HashSet::new(); crate::boxed_vars::collect_compiler_private_async_control_locals_in_stmts( @@ -2301,6 +2358,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> nonnegative_index_methods, guarded_undefined_method_params, pshape_methods, + pshape_arg_methods, pshape_tower_routable, typed_f64_closures: std::collections::HashSet::new(), typed_i32_closures: std::collections::HashSet::new(), diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index 998c542c04..97d21fe568 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -933,6 +933,12 @@ pub(crate) struct CrossModuleCtx { /// compiled for and `this` cannot have a different subclass chain. pub pshape_methods: 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. + pub pshape_arg_methods: + std::collections::HashMap<(String, String), crate::collectors::ProvenShapeArgPlan>, /// #7142: the subset of [`Self::pshape_methods`] whose clone the class-id /// dispatch tower may route to. The other two routing sites are dominated by /// a shape guard they pay regardless, so the clone is free for them; the diff --git a/crates/perry-codegen/src/codegen/ordinary_method_artifacts.rs b/crates/perry-codegen/src/codegen/ordinary_method_artifacts.rs new file mode 100644 index 0000000000..1837a38aab --- /dev/null +++ b/crates/perry-codegen/src/codegen/ordinary_method_artifacts.rs @@ -0,0 +1,305 @@ +//! Emits the ordinary tagged-ABI method body and its additive guarded clones. +//! Kept separate from the artifact traversal so clone families stay grouped +//! without pushing that orchestration module over the source-size gate. + +use std::collections::{HashMap, HashSet}; + +use anyhow::{Context, Result}; + +use crate::module::LlModule; +use crate::strings::StringPool; + +use super::method::compile_method; +use super::opts::CrossModuleCtx; +use super::typed_abi::TypedFunctionTrampolineKind; + +pub(super) struct OrdinaryMethodArtifactsCtx<'a> { + pub llmod: &'a mut LlModule, + pub class: &'a perry_hir::Class, + pub method: &'a perry_hir::Function, + pub func_names: &'a HashMap, + pub strings: &'a mut StringPool, + pub classes: &'a HashMap, + pub methods: &'a HashMap<(String, String), String>, + pub module_globals: &'a HashMap, + pub module_global_types: &'a HashMap, + pub import_function_prefixes: &'a HashMap, + pub enums: &'a HashMap<(String, String), perry_hir::EnumValue>, + pub static_field_globals: &'a HashMap<(String, String), String>, + pub class_ids: &'a HashMap, + pub func_signatures: &'a HashMap, + pub func_synthetic_arguments: &'a HashSet, + pub module_boxed_vars: &'a HashSet, + pub closure_rest_params: &'a HashMap, + pub cross_module: &'a CrossModuleCtx, +} + +pub(super) fn compile_ordinary_method_artifacts( + c: OrdinaryMethodArtifactsCtx<'_>, + typed_public_trampoline: Option, +) -> Result<()> { + let OrdinaryMethodArtifactsCtx { + llmod, + class, + method, + func_names, + strings, + classes, + methods, + module_globals, + module_global_types, + import_function_prefixes, + enums, + static_field_globals, + class_ids, + func_signatures, + func_synthetic_arguments, + module_boxed_vars, + closure_rest_params, + cross_module, + } = c; + + compile_method( + llmod, + class, + method, + func_names, + strings, + classes, + methods, + module_globals, + module_global_types, + import_function_prefixes, + enums, + static_field_globals, + class_ids, + func_signatures, + func_synthetic_arguments, + module_boxed_vars, + closure_rest_params, + cross_module, + typed_public_trampoline, + cross_module + .typed_f64_receiver_methods + .contains_key(&(class.name.clone(), method.name.clone())), + None, + None, + false, + false, + false, + false, + ) + .with_context(|| format!("lowering method '{}::{}'", class.name, method.name))?; + + if cross_module + .guarded_undefined_method_params + .contains_key(&(class.name.clone(), method.name.clone())) + { + compile_method( + llmod, + class, + method, + func_names, + strings, + classes, + methods, + module_globals, + module_global_types, + import_function_prefixes, + enums, + static_field_globals, + class_ids, + func_signatures, + func_synthetic_arguments, + module_boxed_vars, + closure_rest_params, + cross_module, + None, + false, + None, + None, + false, + false, + true, + false, + ) + .with_context(|| { + format!( + "lowering exact-undefined clone of method '{}::{}'", + class.name, method.name + ) + })?; + } + + // Representation-selection Phase 5a: the additive `internal` + // proven-`this` clone. Same HIR, same ABI, same shadow-bound tagged-at-rest + // receiver slot; only `this.field` lowering differs. It is reached solely + // from call sites that already prove the receiver's exact shape. + if let Some(fact) = cross_module + .pshape_methods + .get(&(class.name.clone(), method.name.clone())) + { + compile_method( + llmod, + class, + method, + func_names, + strings, + classes, + methods, + module_globals, + module_global_types, + import_function_prefixes, + enums, + static_field_globals, + class_ids, + func_signatures, + func_synthetic_arguments, + module_boxed_vars, + closure_rest_params, + cross_module, + None, + false, + Some(fact.clone()), + None, + false, + false, + false, + false, + ) + .with_context(|| { + format!( + "lowering proven-`this` clone of method '{}::{}'", + class.name, method.name + ) + })?; + + if cross_module + .guarded_undefined_method_params + .contains_key(&(class.name.clone(), method.name.clone())) + { + compile_method( + llmod, + class, + method, + func_names, + strings, + classes, + methods, + module_globals, + module_global_types, + import_function_prefixes, + enums, + static_field_globals, + class_ids, + func_signatures, + func_synthetic_arguments, + module_boxed_vars, + closure_rest_params, + cross_module, + None, + false, + Some(fact.clone()), + None, + false, + false, + true, + false, + ) + .with_context(|| { + format!( + "lowering proven-`this` exact-undefined clone of method '{}::{}'", + class.name, method.name + ) + })?; + } + + // #8607: a stricter provenance+containment clone. Synthetic immutable + // aliases keep stable array-valued fields in local slots so existing + // local-array loop optimizations see through repeated `this.field`. + if let Some(cached_method) = crate::collectors::ptr_array_cached_method(class, method) { + compile_method( + llmod, + class, + &cached_method, + func_names, + strings, + classes, + methods, + module_globals, + module_global_types, + import_function_prefixes, + enums, + static_field_globals, + class_ids, + func_signatures, + func_synthetic_arguments, + module_boxed_vars, + closure_rest_params, + cross_module, + None, + false, + Some(fact.clone()), + None, + false, + true, + false, + false, + ) + .with_context(|| { + format!( + "lowering contained-receiver array-cache clone of method '{}::{}'", + class.name, method.name + ) + })?; + } + } + + // #8774: internal exact-shape argument clone with the ordinary tagged ABI. + // Every route guards all selected arguments before entry. If the receiver + // is also proven, compose that fact into this same clone. + if cross_module + .pshape_arg_methods + .contains_key(&(class.name.clone(), method.name.clone())) + { + compile_method( + llmod, + class, + method, + func_names, + strings, + classes, + methods, + module_globals, + module_global_types, + import_function_prefixes, + enums, + static_field_globals, + class_ids, + func_signatures, + func_synthetic_arguments, + module_boxed_vars, + closure_rest_params, + cross_module, + None, + false, + cross_module + .pshape_methods + .get(&(class.name.clone(), method.name.clone())) + .cloned(), + None, + false, + false, + false, + true, + ) + .with_context(|| { + format!( + "lowering exact-shape argument clone of method '{}::{}'", + class.name, method.name + ) + })?; + } + + Ok(()) +} diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index 780ca10d48..09add949a7 100644 --- a/crates/perry-codegen/src/collectors/mod.rs +++ b/crates/perry-codegen/src/collectors/mod.rs @@ -32,6 +32,7 @@ mod not_bigint_locals; mod number_by_construction; mod param_ranges; mod pointer_locals; +mod proven_args; mod proven_this; #[cfg(test)] mod proven_this_routing_tests; @@ -86,6 +87,9 @@ pub(crate) use mutation::{body_contains_call, body_contains_closure, has_any_mut pub(crate) use number_by_construction::collect_number_by_construction_locals; pub(crate) use param_ranges::{collect_param_int_ranges, ParamIntRanges}; pub(crate) use pointer_locals::collect_pointer_typed_locals; +pub(crate) use proven_args::{ + method_proven_shape_args, pshape_args_method_name, ProvenShapeArgPlan, +}; pub(crate) use proven_this::{ exportable_method_capabilities as exportable_proven_this_method_capabilities, method_proven_this, prune_unregistered_clones, pshape_method_name, ptr_array_cache_fields, diff --git a/crates/perry-codegen/src/collectors/proven_args.rs b/crates/perry-codegen/src/collectors/proven_args.rs new file mode 100644 index 0000000000..e45598205e --- /dev/null +++ b/crates/perry-codegen/src/collectors/proven_args.rs @@ -0,0 +1,349 @@ +//! Exact-shape facts carried into selected method parameters. +//! +//! A TypeScript parameter annotation can nominate a candidate, but is never +//! proof. For an unannotated JavaScript parameter, a unique local class whose +//! declared fields cover every direct read may nominate the candidate instead. +//! A call site must still prove the argument's exact runtime class and shape +//! before it may enter the clone described here; the ordinary method body +//! remains the fallback for every other value. The clone keeps the public +//! 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. + +use std::collections::{HashMap, HashSet}; + +use perry_hir::types::Type; +use perry_hir::{Class, Expr, Function, Stmt}; + +use super::ptr_shape::{chain_admissible, chain_classes, chain_field_names, PtrShapeLocal}; +use super::ModuleDispatchFacts; + +/// One parameter proven by the call-site guard on entry to `$pshape_args`. +#[derive(Debug, Clone)] +pub struct ProvenShapeArg { + /// Zero-based source/formal argument position (the method receiver is not + /// counted). + pub param_index: usize, + pub param_id: u32, + pub fact: PtrShapeLocal, +} + +/// The single, non-combinatorial exact-shape argument clone for a method. +/// Every listed argument must pass before the call enters the clone. +#[derive(Debug, Clone)] +pub struct ProvenShapeArgPlan { + pub args: Vec, +} + +/// Reserved generated symbol for the method's exact-shape argument clone. +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. +/// +/// 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. +pub(crate) fn method_proven_shape_args( + method: &Function, + classes: &HashMap, + local_class_names: &HashSet<&str>, + module_dispatch: &ModuleDispatchFacts, +) -> 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 + || !method.captures.is_empty() + { + return None; + } + + let mut args = Vec::new(); + for (param_index, param) in method.params.iter().enumerate() { + if param.default.is_some() || param.is_rest || param.arguments_object.is_some() { + continue; + } + let mut use_check = ReadOnlyParamUse { + param_id: param.id, + field_reads: HashSet::new(), + safe: true, + }; + use_check.walk_stmts(&method.body); + if !use_check.safe || use_check.field_reads.is_empty() { + continue; + } + let class_name = match ¶m.ty { + Type::Named(class_name) => { + if !local_class_names.contains(class_name.as_str()) + || !class_fields_cover(classes, class_name, &use_check.field_reads) + { + continue; + } + class_name.clone() + } + // An unannotated JS parameter lowers to `Any`. The field signature + // 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| { + class_fields_cover(classes, class_name, &use_check.field_reads) + }); + let Some(candidate) = candidates.next() else { + continue; + }; + if candidates.next().is_some() { + continue; + } + (*candidate).to_string() + } + _ => continue, + }; + if !chain_admissible(classes, &class_name) { + continue; + } + args.push(ProvenShapeArg { + param_index, + param_id: param.id, + fact: PtrShapeLocal { + class_name, + // An exact shape proves offsets, not the representation of a + // caller-owned field value. + numeric_fields: HashSet::new(), + report_name: crate::opt_report::enabled().then(|| param.name.clone()), + }, + }); + } + + (!args.is_empty()).then_some(ProvenShapeArgPlan { args }) +} + +fn class_fields_cover( + classes: &HashMap, + class_name: &str, + field_reads: &HashSet, +) -> bool { + if !chain_admissible(classes, class_name) { + return false; + } + let fields = chain_field_names(&chain_classes(classes, class_name)); + !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. +pub(super) fn route_preserves_argument_containment( + module_dispatch: &ModuleDispatchFacts, + candidates: &HashMap, + roots: &HashMap, + owner_class: &str, + method: &str, + param_index: usize, + arg: &Expr, +) -> bool { + let Expr::LocalGet(id) = arg else { + return false; + }; + let Some(root) = roots.get(id) else { + return false; + }; + let Some(expected) = module_dispatch.argument_shape_class(owner_class, method, param_index) + else { + return false; + }; + candidates.get(root).is_some_and(|got| got == expected) +} + +struct ReadOnlyParamUse { + param_id: u32, + field_reads: HashSet, + safe: bool, +} + +impl ReadOnlyParamUse { + fn walk_stmts(&mut self, stmts: &[Stmt]) { + for stmt in stmts { + self.walk_stmt(stmt); + } + } + + fn walk_stmt(&mut self, stmt: &Stmt) { + if !self.safe { + return; + } + match stmt { + Stmt::Expr(expr) | Stmt::Throw(expr) | Stmt::Return(Some(expr)) => self.walk_expr(expr), + Stmt::Let { + init: Some(expr), .. + } => self.walk_expr(expr), + Stmt::If { + condition, + then_branch, + else_branch, + } => { + self.walk_expr(condition); + self.walk_stmts(then_branch); + if let Some(branch) = else_branch { + self.walk_stmts(branch); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { condition, body } => { + self.walk_expr(condition); + self.walk_stmts(body); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(init) = init { + self.walk_stmt(init); + } + if let Some(condition) = condition { + self.walk_expr(condition); + } + if let Some(update) = update { + self.walk_expr(update); + } + self.walk_stmts(body); + } + Stmt::Try { + body, + catch, + finally, + } => { + self.walk_stmts(body); + if let Some(catch) = catch { + self.walk_stmts(&catch.body); + } + if let Some(finally) = finally { + self.walk_stmts(finally); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + self.walk_expr(discriminant); + for case in cases { + if let Some(test) = &case.test { + self.walk_expr(test); + } + self.walk_stmts(&case.body); + } + } + Stmt::Labeled { body, .. } => self.walk_stmt(body), + Stmt::Return(None) + | Stmt::Let { init: None, .. } + | Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) + | Stmt::ReleaseBoxes(_) => {} + } + } + + fn walk_expr(&mut self, expr: &Expr) { + if !self.safe { + return; + } + match expr { + // This is the only position that consumes the proof. Do not walk + // the receiver child: its otherwise-bare LocalGet is licensed by + // this declared-field operation. + Expr::PropertyGet { + object, property, .. + } if matches!(object.as_ref(), Expr::LocalGet(id) if *id == self.param_id) => { + self.field_reads.insert(property.clone()); + } + // A direct store/update has frozen/sealed and setter semantics not + // implied by an exact-shape entry guard. + Expr::PropertySet { object, .. } | Expr::PropertyUpdate { object, .. } if matches!(object.as_ref(), Expr::LocalGet(id) if *id == self.param_id) => + { + self.safe = false; + } + Expr::LocalGet(id) if *id == self.param_id => self.safe = false, + 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)); + self.walk_stmts(body); + } + _ => perry_hir::walker::walk_expr_children(expr, &mut |child| self.walk_expr(child)), + } + } +} + +#[cfg(test)] +mod tests { + /// `$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 + /// proven-`this` family. + #[test] + fn pshape_argument_symbol_reachability() { + let src_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let allowed: [&str; 10] = [ + "collectors/proven_args.rs", // naming + this test + "collectors/proven_this.rs", // exact-suffix reachability split + "collectors/ptr_shape.rs", // containment route contract + "collectors/scalar_method_dispatch.rs", // emitted-route metadata + "codegen/argument_shape_clone_tests.rs", // IR/report ratchets + "codegen/method.rs", // clone emission + "codegen/opts.rs", // cross-module context + "expr/mod.rs", // clone parameter proof overlay + "lower_call/method_override.rs", // guarded direct routing + "lower_call/property_get/dynamic_dispatch.rs", // Ptr receiver routing + ]; + let mut offenders = Vec::new(); + + fn visit( + dir: &std::path::Path, + root: &std::path::Path, + allowed: &[&str], + out: &mut Vec, + ) { + for entry in std::fs::read_dir(dir).expect("read src dir") { + let path = entry.expect("dir entry").path(); + if path.is_dir() { + visit(&path, root, allowed, out); + continue; + } + if path.extension().and_then(|ext| ext.to_str()) != Some("rs") { + continue; + } + let rel = path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + if allowed.contains(&rel.as_str()) { + continue; + } + if std::fs::read_to_string(&path) + .expect("read source file") + .contains("$pshape_args") + { + out.push(rel); + } + } + } + + visit(&src_root, &src_root, &allowed, &mut offenders); + assert!( + offenders.is_empty(), + "argument-shape clone symbol fragments found outside the direct-call allowlist: \ + {offenders:?}" + ); + } +} diff --git a/crates/perry-codegen/src/collectors/proven_this.rs b/crates/perry-codegen/src/collectors/proven_this.rs index 87fac47328..58a621b5c6 100644 --- a/crates/perry-codegen/src/collectors/proven_this.rs +++ b/crates/perry-codegen/src/collectors/proven_this.rs @@ -829,7 +829,10 @@ mod tests { continue; } let text = std::fs::read_to_string(&path).expect("read source file"); - if text.contains("$pshape") { + if text + .match_indices("$pshape") + .any(|(offset, _)| !text[offset..].starts_with("$pshape_args")) + { out.push(rel); } } 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 1967b56d3f..efee6f2212 100644 --- a/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs +++ b/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs @@ -225,11 +225,12 @@ fn pshape_call_targets(ir: &str) -> Vec { fn pshape_definitions(ir: &str) -> Vec { ir.lines() - .filter(|l| l.starts_with("define") && l.contains("$pshape")) + .filter(|l| l.starts_with("define")) .filter_map(|l| { let at = l.find('@')?; let paren = l[at..].find('(')?; - Some(l[at + 1..at + paren].to_string()) + let name = &l[at + 1..at + paren]; + name.ends_with("$pshape").then(|| name.to_string()) }) .collect() } diff --git a/crates/perry-codegen/src/collectors/ptr_shape.rs b/crates/perry-codegen/src/collectors/ptr_shape.rs index 223ece44ad..c3e96ff574 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape.rs @@ -445,6 +445,7 @@ pub(crate) fn collect_shape_proven_ptr_locals_and_element_fields( candidates: &candidates, roots: &roots, classes, + module_dispatch, disqualified: HashSet::new(), let_counts: HashMap::new(), field_stores: HashMap::new(), @@ -841,6 +842,7 @@ struct UseWalk<'a> { /// Tracked member id (candidate or const alias) -> root candidate id. roots: &'a HashMap, classes: &'a HashMap, + module_dispatch: &'a ModuleDispatchFacts, disqualified: HashSet, let_counts: HashMap, /// root candidate -> (field name, store value) for in-function stores. @@ -1214,9 +1216,22 @@ impl<'a> UseWalk<'a> { .or_default() .push(args.as_slice()); } - for a in args { - // Position-aware: `o.m(o.field)` is safe, - // `o.m(o)` escapes via the LocalGet arm. + for (param_index, a) in args.iter().enumerate() { + // `o.m(o)` escapes unless the audited exact-class + // argument clone makes this position contained. + if resolvable + && super::proven_args::route_preserves_argument_containment( + self.module_dispatch, + self.candidates, + self.roots, + class_name, + property, + param_index, + a, + ) + { + continue; + } self.with_ctx(report::ESC_CALL_ARGUMENT, |w| w.walk_expr(a)); } return; diff --git a/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs b/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs index 8acbf41d89..830e3aa05f 100644 --- a/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs +++ b/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs @@ -99,6 +99,11 @@ pub struct ModuleDispatchFacts { /// Populated by the compile driver from a whole-program pre-pass over /// final HIR, after this module's own barrier/producer facts are collected. imported_return_shapes: HashMap, + /// #8774: exact-shape argument clones installed after clone eligibility is + /// 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>, /// Representation-selection Phase 3b, #7170 R1: `LocalId` -> `FuncId` for /// every local that provably names one closure literal, module-wide. /// @@ -128,6 +133,7 @@ impl Default for ModuleDispatchFacts { return_shape_functions: HashMap::new(), return_shape_methods: HashMap::new(), imported_return_shapes: HashMap::new(), + argument_shape_routes: HashMap::new(), closure_bindings: HashMap::new(), } } @@ -234,6 +240,39 @@ impl ModuleDispatchFacts { self.imported_return_shapes = shapes; } + /// Install the guarded argument-clone capabilities emitted by this + /// module. Clone admission depends on typed-ABI family selection performed + /// by codegen, while ordinary region facts are collected later during + /// artifact emission. + pub(crate) fn install_argument_shape_routes( + &mut self, + 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); + } + } + } + + /// Expected exact argument class for one emitted `$pshape_args` route. + pub(crate) fn argument_shape_class( + &self, + owner_class: &str, + method_name: &str, + param_index: usize, + ) -> Option<&str> { + self.argument_shape_routes + .get(&( + owner_class.to_string(), + method_name.to_string(), + param_index, + )) + .map(String::as_str) + } + /// Representation-selection Phase 3b, #7170 R1: the `FuncId` that /// `LocalGet(local_id)` in callee position provably names, or `None`. /// @@ -257,6 +296,7 @@ pub fn collect_module_dispatch_facts(hir: &Module) -> ModuleDispatchFacts { return_shape_functions: HashMap::new(), return_shape_methods: HashMap::new(), imported_return_shapes: HashMap::new(), + argument_shape_routes: HashMap::new(), // #7170 R1. Purely structural — no barrier flag feeds it, and it is // read only through `closure_binding_func`, whose every consumer treats // `None` as "take no seed". Computed here rather than lazily so the one @@ -730,6 +770,7 @@ mod tests { return_shape_functions: HashMap::new(), return_shape_methods: HashMap::new(), imported_return_shapes: HashMap::new(), + argument_shape_routes: HashMap::new(), closure_bindings: HashMap::new(), } } diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index e9f48237eb..54eb58b38b 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -1061,12 +1061,21 @@ pub(crate) struct FnCtx<'a> { /// keeps today's guarded lowering because its receiver is unproven. pub proven_this: Option, + /// #8774: parameter-local exact-shape proofs installed only in a guarded + /// `$pshape_args` method clone. Like `proven_this`, each value remains a + /// tagged JSValue in its ordinary shadow-bound slot; field lowering reloads + /// that slot before deriving a raw pointer. + pub proven_shape_params: std::collections::HashMap, + /// Phase 5a: `(class, method)` pairs with an emitted proven-`this` clone. /// The two proven call sites consult this before routing; a hit also /// proves the receiver's exact class DECLARES the method (own /// declarations only), which is what rules out a subclass `this`. pub pshape_methods: &'a std::collections::HashMap<(String, String), crate::collectors::PtrShapeLocal>, + /// #8774: module-local guarded exact-shape parameter clone plans. + pub pshape_arg_methods: + &'a std::collections::HashMap<(String, String), crate::collectors::ProvenShapeArgPlan>, /// Module-local methods whose nonnegative-index clone was actually /// emitted. Call lowering gates on this registry rather than re-running @@ -2089,7 +2098,10 @@ impl<'a> FnCtx<'a> { return None; } match e { - perry_hir::Expr::LocalGet(id) => self.native_facts.shape_proven_ptr_local(*id), + perry_hir::Expr::LocalGet(id) => self + .proven_shape_params + .get(id) + .or_else(|| self.native_facts.shape_proven_ptr_local(*id)), perry_hir::Expr::This => self.proven_this.as_ref(), _ => None, } @@ -2103,7 +2115,10 @@ impl<'a> FnCtx<'a> { e: &perry_hir::Expr, ) -> Option<&crate::collectors::PtrShapeLocal> { match e { - perry_hir::Expr::LocalGet(id) => self.native_facts.shape_proven_ptr_local(*id), + perry_hir::Expr::LocalGet(id) => self + .proven_shape_params + .get(id) + .or_else(|| self.native_facts.shape_proven_ptr_local(*id)), perry_hir::Expr::This => self.proven_this.as_ref(), _ => None, } diff --git a/crates/perry-codegen/src/lower_call/method_override.rs b/crates/perry-codegen/src/lower_call/method_override.rs index 8d3a1e08a5..c6276cb405 100644 --- a/crates/perry-codegen/src/lower_call/method_override.rs +++ b/crates/perry-codegen/src/lower_call/method_override.rs @@ -124,6 +124,166 @@ pub(crate) fn emit_inline_direct_method_shape_guard( } } +/// Emit the exact ordinary-object `(class_id, ShapeId)` guard used by a +/// `$pshape_args` route. Unlike the method-receiver guard above this does not +/// consult prototype-method invalidation state: it proves field offsets only. +/// Descriptor-bearing, forwarded, proxy, subclass, wrong-class, and mutated- +/// shape values all take `fallback_label` before any clone field access. +fn emit_inline_exact_argument_shape_guard( + ctx: &mut FnCtx<'_>, + value: &str, + expected_class_id: u32, + expected_shape_id: &str, + fast_label: &str, + fallback_label: &str, +) { + let deref_idx = ctx.new_block("pshape_arg.guard_deref"); + let deref_label = ctx.block_label(deref_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 blk = ctx.block(); + let bits = blk.bitcast_double_to_i64(value); + let handle = blk.and(I64, &bits, crate::nanbox::POINTER_MASK_I64); + let tag = blk.lshr(I64, &bits, "48"); + let tagged = blk.icmp_eq(I64, &tag, POINTER_TAG_HI16); + let above_floor = blk.icmp_uge(I64, &handle, &heap_floor); + let below_ceiling = blk.icmp_ult(I64, &handle, &heap_ceiling); + let in_heap = blk.and(I1, &above_floor, &below_ceiling); + let safe_to_deref = blk.and(I1, &tagged, &in_heap); + blk.cond_br(&safe_to_deref, &deref_label, fallback_label); + } + + ctx.current_block = deref_idx; + { + let blk = ctx.block(); + let bits = blk.bitcast_double_to_i64(value); + let handle = blk.and(I64, &bits, crate::nanbox::POINTER_MASK_I64); + let obj_ptr = blk.inttoptr(I64, &handle); + let gc_header_ptr = blk.gep(I8, &obj_ptr, &[(I64, "-8")]); + let gc_header = blk.load(I32, &gc_header_ptr); + let guarded_gc_bits = blk.and(I32, &gc_header, GC_OBJECT_METHOD_GUARD_MASK_I32); + let gc_header_ok = blk.icmp_eq(I32, &guarded_gc_bits, GC_TYPE_OBJECT); + + let class_shape = blk.load(I64, &obj_ptr); + let expected_shape_i64 = blk.zext(I32, expected_shape_id, I64); + let expected_shape_high = blk.shl(I64, &expected_shape_i64, "32"); + let expected_class_shape = + blk.or(I64, &expected_shape_high, &expected_class_id.to_string()); + let class_shape_ok = blk.icmp_eq(I64, &class_shape, &expected_class_shape); + let shape_id_rel = blk.add(I32, expected_shape_id, SHAPE_ID_BASE_NEG_I32); + let shape_valid = blk.icmp_ult(I32, &shape_id_rel, SHAPE_ID_RANGE_LEN); + let pass = blk.and(I1, &gc_header_ok, &class_shape_ok); + let pass = blk.and(I1, &pass, &shape_valid); + blk.cond_br(&pass, fast_label, fallback_label); + } +} + +/// Route a receiver-proven method call through its exact-shape argument clone. +/// `generic_fn` is the already-selected receiver-safe body for guard failure. +pub(super) fn emit_pshape_argument_dispatch( + ctx: &mut FnCtx<'_>, + receiver_class_name: &str, + property: &str, + direct_fn: &str, + generic_fn: &str, + direct_arg_slices: &[(crate::types::LlvmType, &str)], +) -> Option { + let key = (receiver_class_name.to_string(), property.to_string()); + 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()); + for arg in &plan.args { + let value = direct_arg_slices.get(arg.param_index + 1)?.1.to_string(); + 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, class_id, shape_id)); + } + if guarded.is_empty() { + return None; + } + + let fast_idx = ctx.new_block("pshape_arg.fast"); + let fallback_idx = ctx.new_block("pshape_arg.fallback"); + let merge_idx = ctx.new_block("pshape_arg.merge"); + let intermediate_idxs: Vec = (1..guarded.len()) + .map(|_| ctx.new_block("pshape_arg.guard_next")) + .collect(); + let fast_label = ctx.block_label(fast_idx); + let fallback_label = ctx.block_label(fallback_idx); + let merge_label = ctx.block_label(merge_idx); + + for (index, (_, value, class_id, shape_id)) in guarded.iter().enumerate() { + let pass_label = intermediate_idxs + .get(index) + .map(|block| ctx.block_label(*block)) + .unwrap_or_else(|| fast_label.clone()); + emit_inline_exact_argument_shape_guard( + ctx, + value, + *class_id, + shape_id, + &pass_label, + &fallback_label, + ); + if let Some(next) = intermediate_idxs.get(index) { + ctx.current_block = *next; + } + } + + ctx.current_block = fast_idx; + let fast_value = ctx.block().call(DOUBLE, &clone_fn, direct_arg_slices); + let fast_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = fallback_idx; + let fallback_value = ctx.block().call(DOUBLE, generic_fn, direct_arg_slices); + let fallback_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = merge_idx; + let merged = ctx.block().phi( + DOUBLE, + &[ + (fast_value.as_str(), fast_end.as_str()), + (fallback_value.as_str(), fallback_end.as_str()), + ], + ); + 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(), + "guard_failure_fallback=generic_method".to_string(), + ]; + 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()); + notes.push("argument_provenance=runtime_guarded_declared_candidate".to_string()); + } + ctx.record_lowered_value( + "MethodCall", + None, + "proven_shape_argument_method_call", + &LoweredValue::js_value(merged.clone()), + None, + None, + None, + false, + false, + notes, + ); + Some(merged) +} + #[cfg(test)] mod packed_guard_tests { use super::*; @@ -1036,70 +1196,88 @@ pub(super) fn emit_guarded_direct_method_call( ); result } else { - // Representation-selection Phase 5a: this arm is reached ONLY - // after `js_method_direct_shape_guard` / - // `js_typed_feedback_method_direct_call_guard` matched the exact - // class id AND the keys token — i.e. the receiver's shape is - // already proven, and the proof is then thrown away by calling the - // guard-ridden public body. Route to the proven-`this` clone - // instead; identical ABI, so only the callee name changes. - // - // A `pshape_methods` hit additionally proves `receiver_class_name` - // DECLARES `property` (locally by analysis or across modules by a - // producer-authored capability), so the clone's `this` is exactly - // the class it was compiled for — an inherited `Base::m` reached - // through a subclass receiver never routes here. - // - // NOTE: the per-field `js_typed_feedback_class_field_get_guard` - // loop above is deliberately LEFT IN PLACE. It guards the - // `$typed_f64_recv` clone's bare `load double` field access, and - // the whole-object shape guard does NOT subsume it: an external - // `obj.f = "s"` preserves both the class id and the key set while - // downgrading the slot's raw-f64 layout. The `$pshape` clone - // needs no such guard because it never claims `JsNumber` — its - // bare loads carry generic `JsValue` semantics (see - // `collectors/proven_this.rs`). - // - // `pshape_fn` (computed once at the top of this function, where the - // `perry_static_` exclusion and the declaring-class argument are - // written out) is the same clone the typed arms above now route - // their generic fallbacks to. - let target = nonnegative_index_direct_fn - .or(pshape_fn.as_deref()) - .unwrap_or(direct_fn); - let result = ctx.block().call(DOUBLE, target, direct_arg_slices); - if nonnegative_index_direct_fn.is_none() { - if let Some(pshape) = pshape_fn.as_deref() { - let receiver_provenance = - if ctx.imported_class_sources.contains_key(receiver_class_name) { - "imported_class_metadata" - } else { - "module_local_analysis" - }; - ctx.record_lowered_value( - "MethodCall", - None, - "proven_this_method_direct_call", - &LoweredValue::js_value(result.clone()), - None, - None, - None, - false, - false, - vec![ - format!("typed_clone={pshape}"), - format!("generic_method={direct_fn}"), - format!("receiver_class={receiver_class_name}"), - format!("method={property}"), - format!("receiver_provenance={receiver_provenance}"), - "this_representation=tagged_js_value_exact_shape".to_string(), - "method_identity_guard=required".to_string(), - "generic_dispatch_fallback=js_native_call_method_by_id".to_string(), - ], - ); + // #8774: the receiver guard dominating this block already proves + // method identity. Guard the selected object arguments here and + // enter the tagged `$pshape_args` body only when every exact shape + // matches. An argument miss stays on the receiver-safe ordinary + // body; a receiver miss is still handled by the outer dynamic + // fallback. + let pshape_arg_fallback = pshape_fn.as_deref().unwrap_or(direct_fn); + if let Some(argument_specialized) = emit_pshape_argument_dispatch( + ctx, + receiver_class_name, + property, + direct_fn, + pshape_arg_fallback, + direct_arg_slices, + ) { + argument_specialized + } else { + // Representation-selection Phase 5a: this arm is reached ONLY + // after `js_method_direct_shape_guard` / + // `js_typed_feedback_method_direct_call_guard` matched the exact + // class id AND the keys token — i.e. the receiver's shape is + // already proven, and the proof is then thrown away by calling the + // guard-ridden public body. Route to the proven-`this` clone + // instead; identical ABI, so only the callee name changes. + // + // A `pshape_methods` hit additionally proves `receiver_class_name` + // DECLARES `property` (locally by analysis or across modules by a + // producer-authored capability), so the clone's `this` is exactly + // the class it was compiled for — an inherited `Base::m` reached + // through a subclass receiver never routes here. + // + // NOTE: the per-field `js_typed_feedback_class_field_get_guard` + // loop above is deliberately LEFT IN PLACE. It guards the + // `$typed_f64_recv` clone's bare `load double` field access, and + // the whole-object shape guard does NOT subsume it: an external + // `obj.f = "s"` preserves both the class id and the key set while + // downgrading the slot's raw-f64 layout. The `$pshape` clone + // needs no such guard because it never claims `JsNumber` — its + // bare loads carry generic `JsValue` semantics (see + // `collectors/proven_this.rs`). + // + // `pshape_fn` (computed once at the top of this function, where the + // `perry_static_` exclusion and the declaring-class argument are + // written out) is the same clone the typed arms above now route + // their generic fallbacks to. + let target = nonnegative_index_direct_fn + .or(pshape_fn.as_deref()) + .unwrap_or(direct_fn); + let result = ctx.block().call(DOUBLE, target, direct_arg_slices); + if nonnegative_index_direct_fn.is_none() { + if let Some(pshape) = pshape_fn.as_deref() { + let receiver_provenance = + if ctx.imported_class_sources.contains_key(receiver_class_name) { + "imported_class_metadata" + } else { + "module_local_analysis" + }; + ctx.record_lowered_value( + "MethodCall", + None, + "proven_this_method_direct_call", + &LoweredValue::js_value(result.clone()), + None, + None, + None, + false, + false, + vec![ + format!("typed_clone={pshape}"), + format!("generic_method={direct_fn}"), + format!("receiver_class={receiver_class_name}"), + format!("method={property}"), + format!("receiver_provenance={receiver_provenance}"), + "this_representation=tagged_js_value_exact_shape".to_string(), + "method_identity_guard=required".to_string(), + "generic_dispatch_fallback=js_native_call_method_by_id".to_string(), + ], + ); + } } + result } - result } }; let after_fast = ctx.block().label.clone(); diff --git a/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs b/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs index ed6316c519..fdd441aec7 100644 --- a/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs +++ b/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs @@ -15,7 +15,8 @@ use crate::types::{DOUBLE, I1, I32, I64}; // Reach the override-emit helpers (`pub(super)` of `lower_call`) by their // canonical crate-relative path. use crate::lower_call::method_override::{ - emit_guarded_direct_method_call, emit_own_method_override_check, SubclassDispatchArm, + emit_guarded_direct_method_call, emit_own_method_override_check, emit_pshape_argument_dispatch, + SubclassDispatchArm, }; /// Cap on the number of extra `(class id, keys token)` arms a shape-guarded @@ -1457,6 +1458,20 @@ pub(crate) fn try_lower_instance_method_call( .or(ptr_array_cache_target.as_deref()) .or(pshape_target.as_deref()) .unwrap_or(fallback_fn.as_str()); + // #8774: containment proves the receiver here, while the + // dedicated argument guards prove every selected callee + // parameter. A miss calls the same receiver-safe generic + // target this block used before argument specialization. + if let Some(argument_specialized) = emit_pshape_argument_dispatch( + ctx, + &class_name, + property, + &fallback_fn, + generic_target, + &arg_slices, + ) { + return Ok(Some(argument_specialized)); + } // Prefer the typed-receiver clone (bare gep+load field // access inside the body) when one exists: the receiver // is proven, so only the ARGUMENT value classes need diff --git a/crates/perry-hir/src/lower/expr_object.rs b/crates/perry-hir/src/lower/expr_object.rs index 96f2f922fe..fe3d5372a8 100644 --- a/crates/perry-hir/src/lower/expr_object.rs +++ b/crates/perry-hir/src/lower/expr_object.rs @@ -19,7 +19,8 @@ use swc_common::Spanned; use swc_ecma_ast as ast; use crate::analysis::{ - closure_uses_this, collect_assigned_locals_stmt, collect_local_refs_stmt, uses_this_stmt, + closure_uses_this, collect_assigned_locals_stmt, collect_local_refs_expr, + collect_local_refs_stmt, uses_this_stmt, }; use crate::ir::{EnumValue, Expr, Function, Param, Stmt}; use crate::lower_decl::{ @@ -1055,6 +1056,55 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R } } + // A static-key, no-spread method literal does not need the synthetic + // IIFE once all of its lowered values are independent of the hidden + // home-object parameter. Emit a normal `Expr::Object` instead: codegen + // allocates its final shape once and fills slots by index, preserving + // source evaluation order without allocating/calling a closure merely + // to mutate `{}` one property at a time. + // + // Methods containing `super` capture `param_id` as their home object. + // Those fail closed and retain the IIFE, as do computed keys, spreads, + // accessors, prototype setters, and any other source-ordered op. A + // method that only observes dynamic `this` is safe here: the ordinary + // object-literal lowering already patches its reserved receiver slot. + let value_is_home_independent = |value: &Expr| { + let mut refs = Vec::new(); + let mut visited_closures = std::collections::HashSet::new(); + collect_local_refs_expr(value, &mut refs, &mut visited_closures); + !refs.contains(¶m_id) + }; + let can_emit_static_object = has_method + && !has_spread + && !has_accessor + && !has_computed + && !has_proto_setter + && ops.iter().all(|op| match op { + SpreadOp::Set { + key: Expr::String(_), + value, + infer_name: false, + } => value_is_home_independent(value), + SpreadOp::MethodByName { closure, .. } => value_is_home_independent(closure), + _ => false, + }); + if can_emit_static_object { + let props = ops + .into_iter() + .map(|op| match op { + SpreadOp::Set { + key: Expr::String(key), + value, + infer_name: false, + } => (key, value), + SpreadOp::MethodByName { key, closure } => (key, closure), + _ => unreachable!("static object admission checked every op"), + }) + .collect(); + ctx.exit_scope(scope_mark); + return Ok(Expr::Object(props)); + } + // Pass 2: build the IIFE wrapper. `__o` starts as an empty object // and each op mutates it in source order. let extern_call = |name: &str, args: Vec| Expr::Call { diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index bdc52af2a3..d93aeefc75 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -8,7 +8,7 @@ #![cfg(test)] use super::*; -use crate::ir::{EnumValue, Stmt}; +use crate::ir::{EnumValue, Expr, Stmt}; use crate::types::{Type, TypeParam}; fn make_ctx() -> LoweringContext { @@ -87,6 +87,74 @@ function build(paramBox: unknown) { } } +#[test] +fn static_method_literals_skip_the_builder_iife_but_home_objects_fail_closed() { + let source = r#" +const outer = 4; +const fast = { + plain: 1, + captured(x: number) { return outer + x; }, + dynamicThis(x: number) { return this.plain + x; }, +}; +const withSuper = { read() { return super.value; } }; +const key = "computed"; +const computed = { [key]() { return 1; } }; +"#; + let module = perry_parser::parse_typescript(source, "method-object.ts").expect("source parses"); + let hir = + super::lower_module(&module, "method-object", "method-object.ts").expect("source lowers"); + + let local_init = |name: &str| { + hir.init + .iter() + .find_map(|stmt| match stmt { + Stmt::Let { + name: local_name, + init: Some(init), + .. + } if local_name == name => Some(init), + _ => None, + }) + .unwrap_or_else(|| panic!("missing init for {name}")) + }; + + let Expr::Object(props) = local_init("fast") else { + panic!( + "static method literal should be a direct object: {:#?}", + hir.init + ); + }; + assert_eq!( + props + .iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + ["plain", "captured", "dynamicThis"] + ); + assert!(matches!( + &props[2].1, + Expr::Closure { + captures_this: true, + .. + } + )); + + for name in ["withSuper", "computed"] { + assert!( + matches!( + local_init(name), + Expr::Call { callee, .. } + if matches!( + callee.as_ref(), + Expr::Closure { params, .. } + if params.first().is_some_and(|param| param.name == "__perry_obj_iife") + ) + ), + "{name} must retain the source-ordered home-object IIFE" + ); + } +} + #[test] fn test_lower_function_registration() { let mut ctx = make_ctx(); diff --git a/crates/perry-runtime/src/closure/alloc.rs b/crates/perry-runtime/src/closure/alloc.rs index 70c85b989a..f0c5b3da2b 100644 --- a/crates/perry-runtime/src/closure/alloc.rs +++ b/crates/perry-runtime/src/closure/alloc.rs @@ -12,20 +12,7 @@ crate::perry_thread_local! { static SINGLETON_CLOSURES: RefCell> = RefCell::new(crate::fast_hash::new_ptr_hash_map()); - /// Per-`func_ptr` single-slot cache for closures with captures. - /// Each value is `(last_captures, last_closure)` — when the same - /// closure literal is created again with the SAME capture bits, - /// we return the cached closure; otherwise we allocate a fresh - /// one and replace the slot. - /// - /// One entry per closure literal (bounded by the number of - /// `Expr::Closure` sites in the program), not per - /// `(func_ptr, capture-tuple)` pair — this prevents a closure - /// whose captures vary per call (e.g. - /// `getOrCompute(map, key, () => new Foo(sortedTypes))` capturing - /// a fresh array per call) from filling the cache and crowding - /// out closures with stable captures. - /// Per-`func_ptr` small-LRU cache. Each entry holds up to + /// Per-`func_ptr` small-LRU cache. Each value holds up to /// `MAX_CAPTURED_CLOSURE_SLOTS` (captures-bits, ClosureHeader) /// pairs. Multiple slots are critical for the parallel-instance /// async-await pattern (e.g. `Promise.all` of N async closures @@ -34,10 +21,207 @@ crate::perry_thread_local! { /// `PtrHasher`-keyed for the same reason as the other registries /// here — on `promise_all_chains` this is hit on every closure /// alloc (150 k/run). - static SINGLETON_CAPTURED_CLOSURES: RefCell, *mut ClosureHeader)>>> = + static SINGLETON_CAPTURED_CLOSURES: RefCell> = RefCell::new(crate::fast_hash::new_ptr_hash_map()); } +#[derive(Clone)] +struct CapturedClosureEntry { + /// Non-semantic prefilter for `captures`. Hash collisions always fall + /// through to the exact bitwise tuple comparison below. + fingerprint: u64, + captures: Vec, + closure: *mut ClosureHeader, + last_used: u64, +} + +/// Direct-mapped index into the exact entry vector. A collision only falls +/// back to the bounded scan; neither the fingerprint nor this hint is ever +/// trusted without exact capture-bit equality. +const CAPTURED_HINT_SLOTS: usize = 128; + +struct CapturedClosureCache { + entries: Vec, + hint_fingerprints: [u64; CAPTURED_HINT_SLOTS], + hint_indices_plus_one: [u8; CAPTURED_HINT_SLOTS], + clock: u64, +} + +impl CapturedClosureCache { + fn new() -> Self { + Self { + entries: Vec::new(), + hint_fingerprints: [0; CAPTURED_HINT_SLOTS], + hint_indices_plus_one: [0; CAPTURED_HINT_SLOTS], + clock: 0, + } + } + + #[inline] + fn hint_slot(fingerprint: u64) -> usize { + // Fold high bits before masking: capture pointers are aligned and FNV's + // low bits alone otherwise make avoidable direct-map collisions. + (fingerprint ^ (fingerprint >> 32)) as usize & (CAPTURED_HINT_SLOTS - 1) + } + + #[inline] + fn touch(&mut self, index: usize) -> *mut ClosureHeader { + self.clock = self.clock.wrapping_add(1); + self.entries[index].last_used = self.clock; + self.entries[index].closure + } + + fn lookup(&mut self, fingerprint: u64, captures: &[u64]) -> Option<*mut ClosureHeader> { + let hint_slot = Self::hint_slot(fingerprint); + let hinted = self.hint_indices_plus_one[hint_slot]; + if hinted != 0 && self.hint_fingerprints[hint_slot] == fingerprint { + let index = (hinted - 1) as usize; + if self + .entries + .get(index) + .is_some_and(|entry| entry.captures.as_slice() == captures) + { + return Some(self.touch(index)); + } + } + + let index = self.entries.iter().position(|entry| { + entry.fingerprint == fingerprint && entry.captures.as_slice() == captures + })?; + self.hint_fingerprints[hint_slot] = fingerprint; + self.hint_indices_plus_one[hint_slot] = (index + 1) as u8; + Some(self.touch(index)) + } + + fn insert(&mut self, fingerprint: u64, captures: Vec, closure: *mut ClosureHeader) { + self.clock = self.clock.wrapping_add(1); + let entry = CapturedClosureEntry { + fingerprint, + captures, + closure, + last_used: self.clock, + }; + let index = if self.entries.len() < MAX_CAPTURED_CLOSURE_SLOTS { + let index = self.entries.len(); + self.entries.push(entry); + index + } else { + let (index, _) = self + .entries + .iter() + .enumerate() + .min_by_key(|(_, entry)| entry.last_used) + .expect("full captured-closure cache must have an LRU entry"); + self.entries[index] = entry; + index + }; + let hint_slot = Self::hint_slot(fingerprint); + self.hint_fingerprints[hint_slot] = fingerprint; + self.hint_indices_plus_one[hint_slot] = (index + 1) as u8; + } + + fn clear_hints(&mut self) { + self.hint_indices_plus_one.fill(0); + } +} + +#[cfg(test)] +mod captured_closure_cache_tests { + use super::*; + + fn fake_closure(id: usize) -> *mut ClosureHeader { + // The cache treats these as opaque values. No test dereferences them. + (0x1000 + id * std::mem::align_of::()) as *mut ClosureHeader + } + + #[test] + fn direct_hint_collision_falls_back_to_exact_capture_match() { + let first = [0u64]; + let first_fingerprint = capture_fingerprint(&first); + let first_slot = CapturedClosureCache::hint_slot(first_fingerprint); + let second_word = (1..10_000u64) + .find(|&word| { + let fingerprint = capture_fingerprint(&[word]); + fingerprint != first_fingerprint + && CapturedClosureCache::hint_slot(fingerprint) == first_slot + }) + .expect("the bounded search must find a direct-hint collision"); + let second = [second_word]; + let second_fingerprint = capture_fingerprint(&second); + + let mut cache = CapturedClosureCache::new(); + cache.insert(first_fingerprint, first.to_vec(), fake_closure(1)); + cache.insert(second_fingerprint, second.to_vec(), fake_closure(2)); + + // Inserting `second` displaced `first` from their shared hint slot. + // Both lookups must still find their exact tuple via fallback, and + // each fallback must repair the hint for the next lookup. + assert_eq!( + cache.lookup(first_fingerprint, &first), + Some(fake_closure(1)) + ); + assert_eq!( + cache.lookup(first_fingerprint, &first), + Some(fake_closure(1)) + ); + assert_eq!( + cache.lookup(second_fingerprint, &second), + Some(fake_closure(2)) + ); + assert_eq!(cache.lookup(first_fingerprint, &second), None); + } + + #[test] + fn full_cache_evicts_least_recently_used_entry() { + let mut cache = CapturedClosureCache::new(); + for word in 0..MAX_CAPTURED_CLOSURE_SLOTS as u64 { + let captures = vec![word]; + cache.insert( + capture_fingerprint(&captures), + captures, + fake_closure(word as usize + 1), + ); + } + + let retained = [0u64]; + assert_eq!( + cache.lookup(capture_fingerprint(&retained), &retained), + Some(fake_closure(1)) + ); + + let replacement = [MAX_CAPTURED_CLOSURE_SLOTS as u64]; + cache.insert( + capture_fingerprint(&replacement), + replacement.to_vec(), + fake_closure(MAX_CAPTURED_CLOSURE_SLOTS + 1), + ); + + let evicted = [1u64]; + assert_eq!(cache.lookup(capture_fingerprint(&evicted), &evicted), None); + assert_eq!( + cache.lookup(capture_fingerprint(&retained), &retained), + Some(fake_closure(1)) + ); + assert_eq!( + cache.lookup(capture_fingerprint(&replacement), &replacement), + Some(fake_closure(MAX_CAPTURED_CLOSURE_SLOTS + 1)) + ); + } +} + +#[inline] +fn capture_fingerprint(captures: &[u64]) -> u64 { + // FNV-1a over fixed-width capture words. The cache never trusts this as an + // identity: it only avoids calling slice equality (and its outlined + // `memcmp`) for entries that cannot possibly match. + let mut hash = 0xcbf2_9ce4_8422_2325u64 ^ captures.len() as u64; + for &word in captures { + hash ^= word; + hash = hash.wrapping_mul(0x0100_0000_01b3); + } + hash +} + /// Header for heap-allocated closures #[repr(C)] pub struct ClosureHeader { @@ -222,13 +406,21 @@ pub fn scan_singleton_closure_roots_mut(visitor: &mut crate::gc::RuntimeRootVisi }); SINGLETON_CAPTURED_CLOSURES.with(|s| { let mut captured = s.borrow_mut(); - for slots in captured.values_mut() { - for (capture_key, closure) in slots.iter_mut() { - visitor.visit_raw_mut_ptr_slot(closure); - for word in capture_key.iter_mut() { + for cache in captured.values_mut() { + for entry in cache.entries.iter_mut() { + visitor.visit_raw_mut_ptr_slot(&mut entry.closure); + for word in entry.captures.iter_mut() { visitor.visit_heap_word_u64_slot(word); } + // A copying collection may have rewritten pointer-bearing + // capture words. Keep the non-semantic prefilter synchronized + // with the exact tuple that remains authoritative. + entry.fingerprint = capture_fingerprint(&entry.captures); } + // Fingerprints and exact tuples were just rewritten in place. A + // hint is disposable acceleration state; clearing avoids an + // address-derived pre-GC fingerprint/index ever being consulted. + cache.clear_hints(); } }); } @@ -256,8 +448,8 @@ pub(crate) fn test_seed_captured_singleton_closure_cache( SINGLETON_CAPTURED_CLOSURES.with(|s| { s.borrow_mut() .entry(func_ptr as usize) - .or_insert_with(Vec::new) - .insert(0, (capture_key, closure)); + .or_insert_with(CapturedClosureCache::new) + .insert(capture_fingerprint(&capture_key), capture_key, closure); }); } @@ -275,7 +467,13 @@ pub(crate) fn test_captured_singleton_closure_cache_entries( SINGLETON_CAPTURED_CLOSURES.with(|s| { s.borrow() .get(&(func_ptr as usize)) - .cloned() + .map(|cache| { + cache + .entries + .iter() + .map(|entry| (entry.captures.clone(), entry.closure)) + .collect() + }) .unwrap_or_default() }) } @@ -331,6 +529,7 @@ pub extern "C" fn js_closure_alloc_with_captures_singleton( } else { unsafe { std::slice::from_raw_parts(captures_ptr, n) } }; + let fingerprint = capture_fingerprint(captures_slice); // Adaptive bypass: if this func_ptr has missed the cache N times in // a row, skip the cache entirely. Async-step closures (`__step` / @@ -362,25 +561,15 @@ pub extern "C" fn js_closure_alloc_with_captures_singleton( return allocated; } - // Fast path: scan the per-`func_ptr` slot list looking for a - // matching capture-tuple. We touch only the cached `Vec` (small, - // bounded by MAX_CAPTURED_CLOSURE_SLOTS). The match check is - // bit-equality of u64 capture slots — same as a plain primitive - // value comparison. Move the matched entry to the front to keep - // recency information for the LRU eviction policy below. + // Fast path: use the tuple fingerprint's direct-mapped hint, then exact + // bit-equality of every capture slot. Hint collisions fall back to the + // bounded entry scan and repair the hint. Entries carry a monotonic + // last-use timestamp, so lookups no longer memmove the Vec yet full-cache + // eviction preserves the same least-recently-used policy. if let Some(cached) = SINGLETON_CAPTURED_CLOSURES.with(|s| { let mut s = s.borrow_mut(); - if let Some(slots) = s.get_mut(&(func_ptr as usize)) { - for i in 0..slots.len() { - if slots[i].0.as_slice() == captures_slice { - let entry = slots.remove(i); - let ptr = entry.1; - slots.insert(0, entry); - return Some(ptr); - } - } - } - None + s.get_mut(&(func_ptr as usize)) + .and_then(|cache| cache.lookup(fingerprint, captures_slice)) }) { crate::promise::bump(&CLOSURE_CAP_SINGLETON_HIT); // Cache hit — reset the streak so a workload that briefly @@ -419,11 +608,13 @@ pub extern "C" fn js_closure_alloc_with_captures_singleton( } SINGLETON_CAPTURED_CLOSURES.with(|s| { let mut s = s.borrow_mut(); - let slots = s.entry(func_ptr as usize).or_insert_with(Vec::new); - slots.insert(0, (rewritten_captures, allocated)); - if slots.len() > MAX_CAPTURED_CLOSURE_SLOTS { - slots.truncate(MAX_CAPTURED_CLOSURE_SLOTS); - } + s.entry(func_ptr as usize) + .or_insert_with(CapturedClosureCache::new) + .insert( + capture_fingerprint(&rewritten_captures), + rewritten_captures, + allocated, + ); }); // Bump the miss-streak counter; flip to disabled sentinel when we // hit the threshold. diff --git a/crates/perry/tests/issue_8774_argument_shape_clones.rs b/crates/perry/tests/issue_8774_argument_shape_clones.rs new file mode 100644 index 0000000000..fc5fa382f9 --- /dev/null +++ b/crates/perry/tests/issue_8774_argument_shape_clones.rs @@ -0,0 +1,351 @@ +//! Exact-shape ordinary-argument clone-and-route coverage (#8774). +//! +//! The fast fixture pins compiler output and the public checksum. The semantic +//! fixture drives every guard-failure family against Node, both normally and +//! while the copying collector relocates live objects. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::Once; + +const GC_ENV_OVERRIDES: &[&str] = &[ + "PERRY_GEN_GC", + "PERRY_GC_SCAVENGE", + "PERRY_GC_SCAVENGE_NURSERY_MB", + "PERRY_GC_MOVING_SAFEPOINT", + "PERRY_GC_MOVING_LOOP_POLLS", + "PERRY_GC_FORCE_EVACUATE", + "PERRY_GC_VERIFY_EVACUATION", + "PERRY_GC_DIAG", + "PERRY_CONSERVATIVE_STACK_SCAN", + "PERRY_WRITE_BARRIERS", + "PERRY_GC_INCREMENTAL", + "PERRY_GC_HEAP_LIMIT", +]; + +fn remove_gc_env_overrides(command: &mut Command) { + for key in GC_ENV_OVERRIDES { + command.env_remove(key); + } +} + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("canonicalize workspace root") +} + +fn target_debug_dir() -> PathBuf { + if let Some(dir) = std::env::var_os("PERRY_TEST_RUNTIME_DIR") { + return PathBuf::from(dir); + } + let target = std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| workspace_root().join("target")); + if cfg!(windows) { + target.join("x86_64-pc-windows-msvc").join("debug") + } else { + target.join("debug") + } +} + +fn ensure_runtime_archive() { + static BUILD_RUNTIME: Once = Once::new(); + BUILD_RUNTIME.call_once(|| { + let runtime_dir = target_debug_dir(); + let runtime_name = if cfg!(windows) { + "perry_runtime.lib" + } else { + "libperry_runtime.a" + }; + let stdlib_name = if cfg!(windows) { + "perry_stdlib.lib" + } else { + "libperry_stdlib.a" + }; + if runtime_dir.join(runtime_name).is_file() && runtime_dir.join(stdlib_name).is_file() { + return; + } + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let mut command = Command::new(cargo); + command + .current_dir(workspace_root()) + .arg("build") + .arg("-p") + .arg("perry-runtime-static") + .arg("-p") + .arg("perry-stdlib-static"); + if cfg!(windows) { + command.arg("--target").arg("x86_64-pc-windows-msvc"); + } + let build = command.output().expect("build static runtime archives"); + assert_success("static runtime build", &build); + }); +} + +fn assert_success(label: &str, output: &Output) { + assert!( + output.status.success(), + "{label} failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn compile(dir: &Path, entry: &str, explain: bool) -> (PathBuf, Output) { + ensure_runtime_archive(); + let output = dir.join(format!("{entry}.bin")); + let mut command = Command::new(perry_bin()); + command + .current_dir(dir) + .arg("compile") + .arg(entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .arg("--trace") + .arg("llvm") + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .env("PERRY_RUNTIME_DIR", target_debug_dir()) + // The clone contract under test is the portable tagged shadow slot; + // this also avoids Windows' unsupported RS4GC + funclet-EH pairing in + // the exception fixture. + .env("PERRY_RS4GC", "0") + // Compile-time half of the precise-root moving-loop-poll route. + .env("PERRY_GC_MOVING_LOOP_POLLS", "1"); + if explain { + command.arg("--opt-report=json").arg("--explain-lowering"); + } + remove_gc_env_overrides(&mut command); + command.env("PERRY_GC_MOVING_LOOP_POLLS", "1"); + let result = command.output().expect("run perry compile"); + assert_success("perry compile", &result); + (output, result) +} + +fn run(binary: &Path, dir: &Path, moving: bool) -> Output { + let mut command = Command::new(binary); + command.current_dir(dir); + remove_gc_env_overrides(&mut command); + if moving { + command + .env("PERRY_GC_SCAVENGE", "1") + .env("PERRY_GC_SCAVENGE_NURSERY_MB", "1") + .env("PERRY_GC_MOVING_LOOP_POLLS", "1") + .env("PERRY_GC_FORCE_EVACUATE", "1") + .env("PERRY_GC_VERIFY_EVACUATION", "1") + .env("PERRY_GC_INCREMENTAL", "0") + .env("PERRY_CONSERVATIVE_STACK_SCAN", "off") + .env("PERRY_GC_DIAG", "1"); + } + let output = command.output().expect("run compiled fixture"); + assert_success("compiled fixture", &output); + output +} + +fn run_node(dir: &Path, entry: &str) -> Output { + let output = Command::new("node") + .current_dir(dir) + .arg(entry) + .output() + .expect("run Node semantic oracle"); + assert_success("Node semantic oracle", &output); + output +} + +fn copy_minor_relocated_objects(stderr: &str) -> u64 { + stderr + .lines() + .filter_map(|line| line.strip_prefix("[gc-copy-minor] ran ")) + .map(|fields| { + let mut in_place = false; + let mut copied = 0; + let mut promoted = 0; + for field in fields.split_whitespace() { + let Some((key, value)) = field.split_once('=') else { + continue; + }; + match key { + "in_place" => in_place = value == "true", + "copied_objects" => copied = value.parse::().unwrap_or(0), + "promoted_objects" => promoted = value.parse::().unwrap_or(0), + _ => {} + } + } + if in_place { + 0 + } else { + copied + promoted + } + }) + .sum() +} + +fn function_body<'a>(ir: &'a str, marker: &str) -> &'a str { + let start = ir + .match_indices("define ") + .find(|(index, _)| { + let end = ir[*index..] + .find('\n') + .map(|offset| index + offset) + .unwrap_or(ir.len()); + ir[*index..end].contains(marker) + }) + .map(|(index, _)| index) + .unwrap_or_else(|| panic!("missing function {marker}:\n{ir}")); + let end = ir[start..] + .find("\n}") + .map(|offset| start + offset) + .expect("function terminator"); + &ir[start..end] +} + +fn fixture_dir() -> PathBuf { + workspace_root().join("test-files/fixtures/issue_8774_argument_shapes") +} + +fn copy_semantic_fixture(dir: &Path) { + for file in ["package.json", "foreign.ts", "barrel.ts", "main.ts"] { + std::fs::copy(fixture_dir().join(file), dir.join(file)) + .unwrap_or_else(|error| panic!("copy {file}: {error}")); + } +} + +fn read_native_records(dir: &Path) -> Vec { + let lowering = dir.join(".perry-trace/lowering"); + let run_dir = std::fs::read_dir(&lowering) + .expect("read lowering directory") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .find(|path| path.is_dir()) + .expect("lowering run directory"); + let mut records = Vec::new(); + for entry in std::fs::read_dir(run_dir).expect("read lowering run") { + let path = entry.expect("lowering entry").path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !name.starts_with("perry_native_reps_") || !name.ends_with(".json") { + continue; + } + let artifact: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).expect("read native records")) + .expect("parse native records"); + records.extend( + artifact["records"] + .as_array() + .expect("native record array") + .iter() + .cloned(), + ); + } + records +} + +#[test] +fn stable_argument_clones_are_direct_reported_and_moving_gc_safe() { + let temp = tempfile::tempdir().expect("tempdir"); + std::fs::copy( + workspace_root().join("test-files/test_issue_8774_argument_shape_clones.ts"), + temp.path().join("valid.ts"), + ) + .expect("copy valid fixture"); + let (binary, compile_output) = compile(temp.path(), "valid.ts", true); + + assert_eq!(run_node(temp.path(), "valid.ts").stdout, b"20000500000\n"); + assert_eq!(run(&binary, temp.path(), false).stdout, b"20000500000\n"); + let moving = run(&binary, temp.path(), true); + assert_eq!(moving.stdout, b"20000500000\n"); + let diagnostics = String::from_utf8_lossy(&moving.stderr); + assert!( + copy_minor_relocated_objects(&diagnostics) > 0, + "forced-moving arm relocated no object:\n{diagnostics}" + ); + + let compiler_stdout = String::from_utf8_lossy(&compile_output.stdout); + assert!( + !compiler_stdout.contains("passed as a call argument"), + "valid route retained the retired containment denial:\n{compiler_stdout}" + ); + + let ir = std::fs::read_to_string(temp.path().join(".perry-trace/llvm/valid_ts.ll")) + .expect("read valid LLVM IR"); + for method in ["add", "hash", "clear"] { + let public = format!("perry_method_valid_ts__Registry__{method}"); + let clone = format!("{public}$pshape_args"); + let clone_body = function_body(&ir, &format!("@{clone}(")); + assert!( + clone_body.contains("getelementptr double") && clone_body.contains("inttoptr i64"), + "{clone} must use fixed-offset field access:\n{clone_body}" + ); + assert!( + !clone_body.contains("shape_descriptor_by_id") + && !clone_body.contains("js_typed_feedback_class_field_get_guard"), + "{clone} rebuilt a field IC diamond:\n{clone_body}" + ); + assert!(ir.contains(&format!("call double @{clone}("))); + assert!(ir.contains(&format!("call double @{public}("))); + } + assert!(ir.contains("pshape_arg.fallback")); + + let records = read_native_records(temp.path()); + for method in ["add", "hash", "clear"] { + let suffix = format!("Registry__{method}$pshape_args"); + assert!( + records.iter().any(|record| { + record["consumer"] == "proven_shape_argument_method_call" + && record["notes"].as_array().is_some_and(|notes| { + notes.iter().any(|note| { + note.as_str().is_some_and(|note| { + note.starts_with("argument_clone=") && note.ends_with(&suffix) + }) + }) + }) + }), + "missing explain-lowering selection for {method}: {records:#?}" + ); + } +} + +#[test] +fn guard_failures_match_node_and_unsafe_parameters_stay_generic() { + let temp = tempfile::tempdir().expect("tempdir"); + copy_semantic_fixture(temp.path()); + let (binary, _) = compile(temp.path(), "main.ts", false); + let node = run_node(temp.path(), "main.ts"); + let ordinary = run(&binary, temp.path(), false); + let moving = run(&binary, temp.path(), true); + assert_eq!( + ordinary.stdout, node.stdout, + "ordinary Perry differs from Node" + ); + assert_eq!( + moving.stdout, node.stdout, + "moving-GC Perry differs from Node" + ); + + let ir = std::fs::read_to_string(temp.path().join(".perry-trace/llvm/main_ts.ll")) + .expect("read semantic LLVM IR"); + for method in ["read", "throws"] { + assert!( + ir.contains(&format!("Registry__{method}$pshape_args")), + "safe declared-field method should get a clone: {method}\n{ir}" + ); + } + for method in ["alias", "reassign"] { + assert!( + !ir.contains(&format!("Registry__{method}$pshape_args")), + "aliased/reassigned parameter must stay generic: {method}\n{ir}" + ); + } + assert!( + !ir.contains("ForeignReader__read$pshape_args"), + "an imported argument class must stay on the generic route:\n{ir}" + ); + assert!(ir.contains("pshape_arg.fallback")); +} diff --git a/crates/perry/tests/static_method_object_literal.rs b/crates/perry/tests/static_method_object_literal.rs new file mode 100644 index 0000000000..453ba9d593 --- /dev/null +++ b/crates/perry/tests/static_method_object_literal.rs @@ -0,0 +1,142 @@ +//! Runtime regression for direct lowering of static-key method literals. +//! +//! Method-only object literals without a `super` home dependency can use the +//! ordinary final-shape object path instead of a synthetic builder IIFE. The +//! controls below cover the source-ordered forms that must retain the IIFE. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::Once; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("canonicalize workspace root") +} + +fn runtime_dir() -> PathBuf { + static BUILD_RUNTIME: Once = Once::new(); + BUILD_RUNTIME.call_once(|| { + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let mut command = Command::new(cargo); + command.current_dir(workspace_root()).arg("build"); + if !cfg!(debug_assertions) { + command.arg("--release"); + } + let build = command + .args(["-p", "perry-runtime-static"]) + .output() + .expect("build static runtime archive"); + assert!( + build.status.success(), + "static runtime build failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&build.stdout), + String::from_utf8_lossy(&build.stderr) + ); + }); + + perry_bin() + .parent() + .expect("Perry binary directory") + .to_path_buf() +} + +fn run_fixture(binary: &Path, force_evacuation: bool) -> Output { + let mut command = Command::new(binary); + if force_evacuation { + command + .env("PERRY_GC_FORCE_EVACUATE", "1") + .env("PERRY_GC_VERIFY_EVACUATION", "1"); + } else { + command + .env_remove("PERRY_GC_FORCE_EVACUATE") + .env_remove("PERRY_GC_VERIFY_EVACUATION"); + } + command.output().expect("run method-literal fixture") +} + +#[test] +fn direct_method_literal_preserves_semantics_and_iife_controls() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let binary = dir.path().join("main_bin"); + std::fs::write( + &entry, + r#" +let order = ""; +function evaluated(label: string, value: number): number { + order += label; + const churn: any[] = []; + for (let i = 0; i < 256; i++) churn.push({ i }); + return value; +} + +const outer = { offset: 7 }; +const fast: any = { + first: evaluated("a", 1), + captured(x: number) { return outer.offset + x; }, + dynamicThis(x: number) { return this.first + x; }, + last: evaluated("b", 3), +}; + +const base: any = { read() { return 10; } }; +const withSuper: any = { read() { return super.read() + 1; } }; +Object.setPrototypeOf(withSuper, base); + +const computedKey = "computed"; +const computed: any = { [computedKey]() { return 9; } }; + +let getterCalls = 0; +const accessor: any = { + get value() { getterCalls++; return 11; }, +}; + +const spread: any = { ...{ x: 1 }, method() { return 2; } }; + +console.log( + order + ":" + Object.keys(fast).join(",") + ":" + fast.captured(5) + ":" + + fast.dynamicThis(5) + ":" + fast.captured.name + ":" + fast.dynamicThis.name + + ":" + withSuper.read() + ":" + computed.computed() + ":" + accessor.value + + ":" + getterCalls + ":" + (spread.x + spread.method()), +); +"#, + ) + .expect("write method-literal fixture"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&binary) + .arg("--no-cache") + .arg("--no-auto-optimize") + .env("PERRY_RUNTIME_DIR", runtime_dir()) + .output() + .expect("compile method-literal fixture"); + assert!( + compile.status.success(), + "compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + for force_evacuation in [false, true] { + let run = run_fixture(&binary, force_evacuation); + assert!( + run.status.success(), + "fixture failed (force_evacuation={force_evacuation})\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + "ab:first,captured,dynamicThis,last:12:6:captured:dynamicThis:11:9:11:1:3\n" + ); + } +} diff --git a/test-files/fixtures/issue_8774_argument_shapes/barrel.ts b/test-files/fixtures/issue_8774_argument_shapes/barrel.ts new file mode 100644 index 0000000000..86a527d5b9 --- /dev/null +++ b/test-files/fixtures/issue_8774_argument_shapes/barrel.ts @@ -0,0 +1 @@ +export { Foreign, installIdAccessor, makeProxy, reshape } from "./foreign.ts"; diff --git a/test-files/fixtures/issue_8774_argument_shapes/foreign.ts b/test-files/fixtures/issue_8774_argument_shapes/foreign.ts new file mode 100644 index 0000000000..4bd3b251f5 --- /dev/null +++ b/test-files/fixtures/issue_8774_argument_shapes/foreign.ts @@ -0,0 +1,38 @@ +export class Foreign { + id: number; + components: number[]; + + constructor(id: number) { + this.id = id; + this.components = [1, 2, 3]; + } +} + +// These mutations deliberately live across an import/re-export boundary. +// The caller module may still emit an argument clone, but the runtime exact- +// shape/descriptor guard must send the changed object to its generic body. +export function reshape(value: any): void { + const id = value.id; + delete value.id; + value.id = id + 1; +} + +export function installIdAccessor(value: any): void { + const id = value.id; + Object.defineProperty(value, "id", { + configurable: true, + enumerable: true, + get() { + return id + 2; + }, + }); +} + +export function makeProxy(value: any, counter: any): any { + return new Proxy(value, { + get(target: any, key: any): any { + counter.hits++; + return target[key]; + }, + }); +} diff --git a/test-files/fixtures/issue_8774_argument_shapes/main.ts b/test-files/fixtures/issue_8774_argument_shapes/main.ts new file mode 100644 index 0000000000..d1a99538ee --- /dev/null +++ b/test-files/fixtures/issue_8774_argument_shapes/main.ts @@ -0,0 +1,112 @@ +import { + Foreign, + installIdAccessor, + makeProxy, + reshape, +} from "./barrel.ts"; + +class Entity { + id: number; + components: number[]; + + constructor(id: number) { + this.id = id; + this.components = []; + } +} + +class SubEntity extends Entity {} + +let accessorHits = 0; +class AccessorEntity { + get id(): number { + accessorHits++; + return 7; + } + + get components(): number[] { + accessorHits++; + return [4, 5]; + } +} + +class Registry { + read(entity: Entity): number { + return entity.id + entity.components.length; + } + + throws(entity: Entity): number { + const id = entity.id; + throw "boom-" + id; + } + + // Bare alias/reassignment uses must keep these methods generic. + alias(entity: Entity): number { + const alias = entity; + return alias.id; + } + + reassign(entity: Entity): number { + entity = new Entity(99); + return entity.id; + } +} + +class ForeignReader { + // Imported class metadata is intentionally not a local clone capability. + read(entity: Foreign): number { + return entity.id + entity.components.length; + } +} + +const registry = new Registry(); +const results: any[] = []; + +const exact = new Entity(2); +exact.components.push(1, 2); +results.push(registry.read(exact)); + +// Wrong class and multiple caller layouts exercise the explicit generic arm. +results.push(registry.read(({ id: 5, components: [1] } as any) as Entity)); +const subclass = new SubEntity(3); +subclass.components.push(8); +results.push(registry.read(subclass)); + +const changed = new Entity(4); +changed.components.push(1); +(changed as any).extra = true; +results.push(registry.read(changed)); + +results.push(registry.read((new AccessorEntity() as any) as Entity)); + +const proxyCounter = { hits: 0 }; +const proxied = makeProxy(new Entity(6), proxyCounter); +results.push(registry.read((proxied as any) as Entity)); + +try { + registry.throws(new Entity(8)); +} catch (error) { + results.push(error); +} + +results.push(registry.alias(new Entity(9))); +results.push(registry.reassign(new Entity(10))); +results.push(new ForeignReader().read(new Foreign(11))); + +const reshaped = new Entity(12); +reshape(reshaped); +results.push(registry.read(reshaped)); + +const descriptor = new Entity(14); +installIdAccessor(descriptor); +results.push(registry.read(descriptor)); + +// Own-method replacement must stay ahead of every argument clone route. +(registry as any).read = function (entity: any): number { + return entity.id * 10; +}; +results.push(registry.read(new Entity(16))); + +console.log( + JSON.stringify({ results, accessorHits, proxyHits: proxyCounter.hits }), +); diff --git a/test-files/fixtures/issue_8774_argument_shapes/package.json b/test-files/fixtures/issue_8774_argument_shapes/package.json new file mode 100644 index 0000000000..3dbc1ca591 --- /dev/null +++ b/test-files/fixtures/issue_8774_argument_shapes/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/test-files/test_issue_8774_argument_shape_clones.ts b/test-files/test_issue_8774_argument_shape_clones.ts new file mode 100644 index 0000000000..daf2489bb8 --- /dev/null +++ b/test-files/test_issue_8774_argument_shape_clones.ts @@ -0,0 +1,40 @@ +// #8774: an exact-shape object passed in an ordinary argument position must +// reach Registry's tagged `$pshape_args` clones. The hot bodies may access +// Entity's declared fields directly; every other runtime shape stays on the +// generic method entry. +class Entity { + constructor(id) { + this.id = id; + this.components = []; + } +} + +class Registry { + add(entity, component) { + entity.components.push(component); + } + + hash(entity) { + let value = entity.id; + for (let i = 0; i < entity.components.length; i++) { + value += entity.components[i]; + } + return value; + } + + clear(entity) { + entity.components.length = 0; + } +} + +const registry = new Registry(); +let checksum = 0; +const iterations = 200_000; +for (let i = 0; i < iterations; i++) { + const entity = new Entity(i); + registry.add(entity, 1); + registry.add(entity, 2); + checksum += registry.hash(entity); + registry.clear(entity); +} +console.log(checksum);