From 81bb397ade4244ce38751b335a0ec7cab91c2ee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 28 Jul 2026 16:05:39 +0200 Subject: [PATCH 1/7] =?UTF-8?q?perf(codegen):=20repsel=20Phase=205a=20?= =?UTF-8?q?=E2=80=94=20Ptr=20proven=20`this`=20in=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit an internal {method}__pshape clone whose `this` carries the Ptr proof, and route the two call sites that already prove the receiver's exact shape to it instead of the guard-ridden public body. --- crates/perry-codegen/src/codegen/artifacts.rs | 46 +++ crates/perry-codegen/src/codegen/closure.rs | 2 + crates/perry-codegen/src/codegen/entry.rs | 4 + crates/perry-codegen/src/codegen/function.rs | 2 + crates/perry-codegen/src/codegen/method.rs | 30 +- crates/perry-codegen/src/codegen/mod.rs | 31 +- crates/perry-codegen/src/codegen/opts.rs | 8 + crates/perry-codegen/src/codegen/typed_abi.rs | 12 + crates/perry-codegen/src/collectors/mod.rs | 2 + .../src/collectors/proven_this.rs | 280 ++++++++++++++++++ .../perry-codegen/src/collectors/ptr_shape.rs | 40 ++- .../src/collectors/scalar_method_dispatch.rs | 32 +- .../perry-codegen/src/expr/instance_misc1.rs | 8 +- crates/perry-codegen/src/expr/mod.rs | 43 +++ crates/perry-codegen/src/expr/property_get.rs | 15 +- .../src/expr/property_get/helpers.rs | 15 +- crates/perry-codegen/src/expr/property_set.rs | 20 +- .../src/lower_call/method_override.rs | 30 +- .../property_get/dynamic_dispatch.rs | 30 +- .../src/type_analysis/predicates.rs | 31 +- .../src/commands/compile/object_cache.rs | 7 + .../object_cache/object_cache_tests.rs | 1 + 22 files changed, 631 insertions(+), 58 deletions(-) create mode 100644 crates/perry-codegen/src/collectors/proven_this.rs diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index 41fff64d1e..e13f57c419 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -415,8 +415,50 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { cross_module .typed_f64_receiver_methods .contains_key(&(class.name.clone(), method.name.clone())), + None, ) .with_context(|| format!("lowering method '{}::{}'", class.name, method.name))?; + // Representation-selection Phase 5a: the additive `internal` + // `{public}__pshape` 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()), + ) + .with_context(|| { + format!( + "lowering proven-`this` clone of method '{}::{}'", + class.name, method.name + ) + })?; + } } for member in class .computed_members @@ -444,6 +486,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { cross_module, None, false, + None, ) .with_context(|| { format!( @@ -508,6 +551,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { cross_module, None, false, + None, ) .with_context(|| format!("lowering getter '{}::{}'", class.name, prop))?; } @@ -560,6 +604,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { cross_module, None, false, + None, ) .with_context(|| format!("lowering setter '{}::{}'", class.name, prop))?; } @@ -654,6 +699,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { cross_module, None, false, + None, ) .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 ea5a9d612a..d9af39655e 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -928,6 +928,8 @@ pub(super) fn compile_closure( typed_i1_functions: &cross_module.typed_i1_functions, 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, + proven_this: None, 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 0314d369ab..2b9596dd76 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -820,6 +820,8 @@ pub(super) fn compile_module_entry( typed_i1_functions: &cross_module.typed_i1_functions, 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, + proven_this: None, typed_i32_methods: &cross_module.typed_i32_methods, typed_i1_methods: &cross_module.typed_i1_methods, typed_string_methods: &cross_module.typed_string_methods, @@ -1437,6 +1439,8 @@ pub(super) fn compile_module_entry( typed_i1_functions: &cross_module.typed_i1_functions, 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, + proven_this: None, 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 5476012a39..62a74ae71a 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -783,6 +783,8 @@ pub(super) fn compile_function( typed_i1_functions: &cross_module.typed_i1_functions, 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, + proven_this: None, 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/method.rs b/crates/perry-codegen/src/codegen/method.rs index bf6cc77747..51dd613ea7 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -261,6 +261,7 @@ pub(super) fn compile_method( cross_module: &CrossModuleCtx, typed_public_trampoline: Option, force_generic_body: bool, + proven_this: Option, ) -> Result<()> { let public_llvm_name = methods .get(&(class.name.clone(), method.name.clone())) @@ -272,7 +273,15 @@ pub(super) fn compile_method( method.name ) })?; - let llvm_name = if typed_public_trampoline.is_some() || force_generic_body { + // Representation-selection Phase 5a: the proven-`this` clone is a SECOND, + // additive body compiled from the same HIR through the same statement + // lowerer. It never replaces the public symbol and never participates in + // 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 llvm_name = if is_pshape_clone { + super::pshape_method_name(&public_llvm_name) + } else if typed_public_trampoline.is_some() || force_generic_body { generic_method_body_name(&public_llvm_name) } else { public_llvm_name.clone() @@ -288,7 +297,7 @@ pub(super) fn compile_method( let ic_base = llmod.ic_counter; let buffer_alias_base = llmod.buffer_alias_counter; let lf = llmod.define_function(&llvm_name, DOUBLE, params); - if typed_public_trampoline.is_some() || force_generic_body { + if is_pshape_clone || typed_public_trampoline.is_some() || force_generic_body { lf.linkage = "internal".to_string(); } @@ -551,6 +560,8 @@ pub(super) fn compile_method( typed_i1_functions: &cross_module.typed_i1_functions, 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, + proven_this, typed_i32_methods: &cross_module.typed_i32_methods, typed_i1_methods: &cross_module.typed_i1_methods, typed_string_methods: &cross_module.typed_string_methods, @@ -1025,10 +1036,15 @@ pub(super) fn compile_method( for raw in &typed_parse_rodata { llmod.add_raw_global(raw.clone()); } - if let Some(kind) = typed_public_trampoline { - emit_public_typed_method_trampoline(llmod, method, &public_llvm_name, &llvm_name, kind); - } else if force_generic_body { - emit_public_generic_method_forwarder(llmod, method, &public_llvm_name, &llvm_name); + // The Phase 5a clone is purely additive: the public symbol (and its + // trampoline/forwarder, if any) belongs to the primary invocation. Emitting + // it again here would define the same symbol twice. + if !is_pshape_clone { + if let Some(kind) = typed_public_trampoline { + emit_public_typed_method_trampoline(llmod, method, &public_llvm_name, &llvm_name, kind); + } else if force_generic_body { + emit_public_generic_method_forwarder(llmod, method, &public_llvm_name, &llvm_name); + } } Ok(()) } @@ -1586,6 +1602,8 @@ pub(super) fn compile_static_method( typed_i1_functions: &cross_module.typed_i1_functions, 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, + proven_this: None, 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 fd888df672..bf38829fd1 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -72,7 +72,8 @@ pub(crate) use opts::{CrossModuleCtx, ImportedCtor}; pub(crate) use spec_abi::{spec_abi_enabled, spec_function_name, SpecDispatch, SpecFnPlan}; pub(crate) use typed_abi::{ emit_typed_arg_guard, emit_typed_arg_to_raw, generic_closure_body_name, - generic_function_body_name, generic_method_body_name, typed_f64_closure_name, + generic_function_body_name, generic_method_body_name, pshape_method_name, + typed_f64_closure_name, typed_f64_function_name, typed_f64_method_name, typed_f64_receiver_method_info, typed_f64_receiver_method_name, typed_i1_closure_name, typed_i1_function_name, typed_i1_method_name, typed_i32_closure_name, typed_i32_function_name, typed_i32_method_name, @@ -1340,6 +1341,17 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> let mut typed_string_methods = std::collections::HashSet::new(); let mut typed_i1_method_param_reps = std::collections::HashMap::new(); let mut typed_f64_receiver_methods = std::collections::HashMap::new(); + // Module-wide dispatch/barrier facts. Hoisted above the typed-clone + // eligibility loop because representation-selection Phase 5a's + // proven-`this` admission consults them (§5.2 shape barriers, the + // freeze family, and `prototype_is_stable`). Moved into `CrossModuleCtx` + // below — computed exactly once per module either way. + let module_dispatch_facts = crate::collectors::collect_module_dispatch_facts(hir); + // Representation-selection Phase 5a: proven-`this` method clones. + let mut pshape_methods: std::collections::HashMap< + (String, String), + crate::collectors::PtrShapeLocal, + > = std::collections::HashMap::new(); // Phase 3b typed-receiver widening: chain-global field indexes need the // full class table — and it must be the SAME table dynamic dispatch's // call-site gating consults (`class_table`, incl. class-expression @@ -1350,6 +1362,20 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> for class in &hir.classes { for method in &class.methods { let source_function = format!("{}::{}", class.name, method.name); + // Representation-selection Phase 5a: does this method admit a + // proven-`this` clone? Uses the SAME class table as the + // typed-receiver decision below, for the same reason — the two + // routing sites gate on this map, and a chain resolvable only + // through an alias would gate a call to a symbol the emission + // loop never produced. + if let Some(fact) = crate::collectors::method_proven_this( + class, + method, + receiver_class_table, + &module_dispatch_facts, + ) { + pshape_methods.insert((class.name.clone(), method.name.clone()), fact); + } match typed_abi::typed_f64_method_rejection_reason(method) { None => { let key = (class.name.clone(), method.name.clone()); @@ -1599,7 +1625,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> compile_time_constants, target_triple: triple.clone(), app_metadata: opts.app_metadata.clone(), - module_dispatch: crate::collectors::collect_module_dispatch_facts(hir), + module_dispatch: module_dispatch_facts, // Inline-hot-small pre-pass (#6850 follow-up): FuncIds with an in-loop // call site AND few total call sites, so small hot callees can earn // `inlinehint` while the call-site cap bounds duplication. @@ -1645,6 +1671,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> typed_string_methods, typed_i1_method_param_reps, typed_f64_receiver_methods, + pshape_methods, typed_f64_closures: std::collections::HashSet::new(), typed_i32_closures: std::collections::HashSet::new(), typed_i1_closures: std::collections::HashSet::new(), diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index d3b5c81d59..a45e4ea2c6 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -797,6 +797,14 @@ pub(crate) struct CrossModuleCtx { /// calling the clone. pub typed_f64_receiver_methods: std::collections::HashMap<(String, String), super::typed_abi::TypedReceiverMethodInfo>, + /// Representation-selection Phase 5a: `(class, method)` pairs that have a + /// generated `internal` `{public}__pshape` clone whose `this` is proven + /// (`collectors/proven_this.rs`). Keys are OWN declarations of + /// module-local classes only, which is precisely the condition the two + /// routing sites rely on: a hit means the receiver's proven exact class is + /// the class the clone was compiled for, so `this` cannot be a subclass + /// instance with a different chain. + pub pshape_methods: std::collections::HashMap<(String, String), crate::collectors::PtrShapeLocal>, /// Inline closure bodies that have a generated internal typed-f64 clone. /// Only statically-known local closure calls may select these clones after /// closure identity/arity and numeric argument guards pass. diff --git a/crates/perry-codegen/src/codegen/typed_abi.rs b/crates/perry-codegen/src/codegen/typed_abi.rs index 88c9172004..0f971d1b5e 100644 --- a/crates/perry-codegen/src/codegen/typed_abi.rs +++ b/crates/perry-codegen/src/codegen/typed_abi.rs @@ -319,6 +319,18 @@ pub(crate) fn generic_method_body_name(generic_name: &str) -> String { format!("{generic_name}__generic") } +/// Representation-selection Phase 5a: the `internal` proven-`this` clone of a +/// method (`collectors/proven_this.rs`). Same `(double this, double args…)` +/// ABI as the public symbol — only the body's `this.field` lowering differs. +/// +/// This symbol is NEVER registered into a runtime vtable +/// (`js_register_class_method` keeps the public name) and is reachable only +/// from the two proven call sites. `pshape_symbol_reachability` in +/// `collectors/proven_this.rs` ratchets that. +pub(crate) fn pshape_method_name(generic_name: &str) -> String { + format!("{generic_name}__pshape") +} + pub(crate) fn generic_closure_body_name(generic_name: &str) -> String { format!("{generic_name}__generic") } diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index 5908e7c569..fe471a24c4 100644 --- a/crates/perry-codegen/src/collectors/mod.rs +++ b/crates/perry-codegen/src/collectors/mod.rs @@ -23,6 +23,7 @@ mod local_refs; mod mutation; mod not_bigint_locals; mod pointer_locals; +mod proven_this; mod ptr_numarray; mod ptr_shape; mod refs; @@ -63,6 +64,7 @@ pub(crate) use local_refs::{expr_contains_local_get, mark_all_candidate_refs_in_ pub(crate) use mutation::has_any_mutation; pub(crate) use pointer_locals::collect_pointer_typed_locals; pub(crate) use ptr_numarray::{NumArrayDensity, NumArrayLocal}; +pub(crate) use proven_this::method_proven_this; pub(crate) use ptr_shape::PtrShapeLocal; pub(crate) use refs::{ collect_let_ids, collect_ref_ids_in_expr, collect_ref_ids_in_stmts, is_clamp_call, diff --git a/crates/perry-codegen/src/collectors/proven_this.rs b/crates/perry-codegen/src/collectors/proven_this.rs new file mode 100644 index 0000000000..3733b2351a --- /dev/null +++ b/crates/perry-codegen/src/collectors/proven_this.rs @@ -0,0 +1,280 @@ +//! Representation-selection Phase 5a (RFC `docs/representation-selection-rfc.md` +//! §4 Object row, §5.4, §5.6): the **proven `this`** of a class method. +//! +//! ## The proof already exists — this phase stops discarding it +//! +//! Two call sites already prove a receiver's exact shape and then call the +//! guard-ridden PUBLIC method body, where every `this.field` re-enters the +//! per-access guarded diamond (`expr/class_field_inline_guard.rs`) whose cost +//! is unhoistable by construction (volatile gate + the fallback arm's opaque +//! call block LICM): +//! +//! 1. `lower_call/method_override.rs` — the `method_direct.fast` arm, entered +//! only after `js_typed_feedback_method_direct_call_guard` / +//! `js_method_direct_shape_guard` matched **class_id AND the keys token**. +//! 2. `lower_call/property_get/dynamic_dispatch.rs` — the Phase 3b guard-free +//! `Ptr` receiver arm, whose receiver is a shape-proven local. +//! +//! Phase 5a emits an `internal` `{public}__pshape` clone of the method whose +//! `this` carries the [`PtrShapeLocal`] proof, and routes those two sites to +//! it. Net new proof work: zero. Net new GC work: zero — the clone keeps the +//! identical `(double this, double args…)` ABI and the identical shadow-bound +//! tagged-at-rest receiver slot (`codegen/method.rs`), because `GC_TYPE_OBJECT` +//! is MOVABLE and the `TaPtr` no-shadow shortcut does not transfer. Net new +//! layout work: zero — field indexes come from `class_field_global_index`, +//! which is chain-global (parent fields first). +//! +//! ## Why the receiver is exactly this class +//! +//! `this` inside `C.prototype.m` can in general be an instance of any SUBCLASS +//! of `C`, which would invalidate a proof rooted at `C`'s chain. Phase 5a +//! sidesteps this entirely at the ROUTING sites rather than here: both of them +//! only route when the receiver's proven exact class **declares** the method +//! (`ctx.methods` holds own-declarations only — `method_registry.rs` never +//! inserts inherited entries), so the class the clone was compiled for is the +//! receiver's exact dynamic class. Inherited dispatch (`d.m()` resolving to +//! `Base::m`) keeps today's lowering. +//! +//! ## `numeric_fields` is deliberately NOT claimed +//! +//! A Phase 3b local claims `JsNumber`/`F64` per field under an EXHAUSTIVE +//! reachable-store proof, which containment makes possible: no alias to the +//! object exists, so the analysis sees every store. A proven `this` is owned by +//! the CALLER and is aliased by construction — `counter.value = "s"` elsewhere +//! is a reachable store this analysis cannot enumerate, and it downgrades the +//! slot's raw-f64 layout at runtime. A guard-free read cannot consult that +//! layout, so claiming `JsNumber` would be unsound. Phase 5a therefore emits +//! bare `load double` with generic `JsValue` semantics — still fully +//! guard-free, and bit-identical (a NaN-boxed number IS its own double bits). +//! Recovering the numeric claim needs a whole-program no-external-store proof; +//! that is deferred. +//! +//! Gated by `PERRY_PTR_SHAPE_THIS` (default on; `0`/`off`/`false` disables — +//! keyed into the object cache). Also honours `PERRY_PTR_SHAPE_LOCALS`, since +//! Phase 5a is an extension of the same `Ptr` proof. + +use std::collections::{HashMap, HashSet}; + +use perry_hir::{Class, Expr, Function}; + +use super::ptr_shape::{ + chain_admissible, chain_field_names, chain_method_map, chain_classes, ptr_shape_locals_enabled, + PtrShapeLocal, ThisFlowAnalysis, +}; +use super::ModuleDispatchFacts; + +/// `PERRY_PTR_SHAPE_THIS` gate. Enabled by default; `=0`/`off`/`false` +/// disables proven-`this` method clones (every `this.field` keeps today's +/// guarded lowering). Keyed into the object cache (`object_cache.rs`). +pub fn ptr_shape_this_enabled() -> bool { + use std::sync::OnceLock; + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + !matches!( + std::env::var("PERRY_PTR_SHAPE_THIS").as_deref(), + Ok("0") | Ok("off") | Ok("false") + ) + }) +} + +/// The `Object.freeze` / `Object.seal` / `Object.preventExtensions` family. +/// +/// Deliberately NOT part of [`super::ptr_shape::expr_is_shape_barrier`] — see +/// `ModuleDispatchFacts::freeze_barrier_sites` for why a Phase 3b local needs +/// no module-wide kill here but a proven `this` does. +pub(crate) fn expr_is_freeze_barrier(expr: &Expr) -> bool { + matches!( + expr, + Expr::ObjectFreeze(_) | Expr::ObjectSeal(_) | Expr::ObjectPreventExtensions(_) + ) +} + +/// Admission test for one instance method. `Some(fact)` means a +/// `{public}__pshape` clone may be emitted, with `fact` installed as +/// `FnCtx::proven_this`. +pub(crate) fn method_proven_this( + class: &Class, + method: &Function, + classes: &HashMap, + module_dispatch: &ModuleDispatchFacts, +) -> Option { + if !ptr_shape_this_enabled() || !ptr_shape_locals_enabled() { + return None; + } + // The module-wide §5.2 barrier kill, shared verbatim with Phase 3b. + if module_dispatch.has_shape_barrier_sites() { + return None; + } + // Context restrictions, same as Phase 1/3a/3b: the async-to-generator + // transform boxes body locals into a shared cell, so `this`-flow facts do + // not survive it. + if method.is_async || method.is_generator || method.was_plain_async { + return None; + } + if !method.captures.is_empty() { + return None; + } + for param in &method.params { + if param.default.is_some() || param.is_rest || param.arguments_object.is_some() { + return None; + } + } + // Class-level admission (accessor-free, computed-free, statically-extended, + // modeled chain) and method-table stability. + if !chain_admissible(classes, &class.name) { + return None; + } + if !module_dispatch.prototype_is_stable(classes, &class.name) { + return None; + } + + let chain = chain_classes(classes, &class.name); + if chain.is_empty() { + return None; + } + let fields = chain_field_names(&chain); + let methods = chain_method_map(&chain); + + // `this`-flow safety: `this` never used as a value (`Expr::This` in value + // position rejects), no closure mentioning `this`, every `this.f = v` + // write to a DECLARED chain field, every internally-invoked `this.m()` / + // `super.m()` vetted transitively. This is the same walk Phase 3b runs + // over the methods called on a proven local. + let mut analysis = ThisFlowAnalysis::new(&chain, &fields, &methods); + if !analysis.method_safe(&class.name, method) { + return None; + } + + // Does the clone (including everything it transitively invokes on the same + // `this`) contain a field WRITE? A guard-free raw store into a frozen or + // sealed receiver would silently succeed where the spec requires a + // strict-mode TypeError, and unlike a Phase 3b local the receiver here is + // aliased by construction. Reads are unaffected — a frozen object still + // reads back exactly the same slot bits. + let writes_fields = analysis.has_this_store_records(); + if writes_fields && module_dispatch.has_freeze_barrier_sites() { + return None; + } + + // The clone must actually remove work: a method that never touches a + // declared field through `this` lowers identically to the public body and + // would be pure code-size bloat. + if !method_reads_or_writes_chain_field(method, &fields) { + return None; + } + + Some(PtrShapeLocal { + class_name: class.name.clone(), + // See the module doc: never claimed for a proven `this`. + numeric_fields: HashSet::new(), + }) +} + +/// Does the method body mention `this.` at all? +fn method_reads_or_writes_chain_field(method: &Function, fields: &HashSet) -> bool { + let mut found = false; + super::scalar_method_dispatch::for_each_expr_in_stmts(&method.body, &mut |e| { + if found { + return; + } + let property = match e { + Expr::PropertyGet { + object, property, .. + } if matches!(object.as_ref(), Expr::This) => { + Some(property) + } + Expr::PropertySet { + object, property, .. + } + | Expr::PropertyUpdate { + object, property, .. + } if matches!(object.as_ref(), Expr::This) => Some(property), + _ => None, + }; + if let Some(property) = property { + if fields.contains(property.as_str()) { + found = true; + } + } + }); + found +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn freeze_family_is_a_barrier() { + assert!(expr_is_freeze_barrier(&Expr::ObjectFreeze(Box::new( + Expr::This + )))); + assert!(expr_is_freeze_barrier(&Expr::ObjectSeal(Box::new( + Expr::This + )))); + assert!(expr_is_freeze_barrier(&Expr::ObjectPreventExtensions( + Box::new(Expr::This) + ))); + // Not a freeze barrier: the Phase 3b §5.2 family is tracked separately. + assert!(!expr_is_freeze_barrier(&Expr::Delete(Box::new(Expr::This)))); + assert!(!expr_is_freeze_barrier(&Expr::This)); + } + + /// The proven-`this` clone suffix must never be registered into a runtime + /// vtable or reachable from any indirect route. This is the Phase 5a twin + /// of `spec_abi_symbol_reachability`; it is deliberately a SEPARATE + /// ratchet rather than a widening of that test's allowlist, so the Phase 2 + /// guarantee stays exactly as tight as it was. + #[test] + fn pshape_symbol_reachability() { + let src_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + // Naming + emission + the two proven call sites. `string_pool.rs` + // (which emits `js_register_class_method`) is deliberately ABSENT: + // the vtable must only ever hold the public symbol. + let allowed: [&str; 6] = [ + "collectors/proven_this.rs", // this test + "codegen/typed_abi.rs", // name helper + "codegen/method.rs", // clone emission + "codegen/artifacts.rs", // emission driver + "lower_call/method_override.rs", // guarded fast-arm routing + "lower_call/property_get/dynamic_dispatch.rs", // guard-free routing + ]; + let mut offenders: Vec = 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(|e| e.to_str()) != Some("rs") { + continue; + } + let rel = path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + if allowed.contains(&rel.as_str()) { + continue; + } + let text = std::fs::read_to_string(&path).expect("read source file"); + if text.contains("__pshape") { + out.push(rel); + } + } + } + visit(&src_root, &src_root, &allowed, &mut offenders); + assert!( + offenders.is_empty(), + "proven-`this` clone symbol fragments found outside the allowlist \ + (the clone is `internal` and must NEVER be registered into a \ + runtime vtable or reached indirectly): {offenders:?}" + ); + } +} diff --git a/crates/perry-codegen/src/collectors/ptr_shape.rs b/crates/perry-codegen/src/collectors/ptr_shape.rs index 455b5c8f22..e439372779 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape.rs @@ -401,7 +401,7 @@ fn collect_alias_edges(stmts: &[Stmt], out: &mut Vec<(u32, u32)>) { /// The chain (self first) when every link is a modeled, accessor-free, /// computed-free, statically-extended user class; `None`-equivalent (empty) /// otherwise. -fn chain_classes<'a>(classes: &HashMap, class_name: &str) -> Vec<&'a Class> { +pub(super) fn chain_classes<'a>(classes: &HashMap, class_name: &str) -> Vec<&'a Class> { let mut out = Vec::new(); let mut current = Some(class_name.to_string()); let mut seen = HashSet::new(); @@ -418,7 +418,7 @@ fn chain_classes<'a>(classes: &HashMap, class_name: &str) -> out } -fn chain_admissible(classes: &HashMap, class_name: &str) -> bool { +pub(super) fn chain_admissible(classes: &HashMap, class_name: &str) -> bool { let chain = chain_classes(classes, class_name); if chain.is_empty() { return false; @@ -452,7 +452,7 @@ fn chain_admissible(classes: &HashMap, class_name: &str) -> bool true } -fn chain_field_names(chain: &[&Class]) -> HashSet { +pub(super) fn chain_field_names(chain: &[&Class]) -> HashSet { let mut out = HashSet::new(); for class in chain { out.extend(class.fields.iter().map(|f| f.name.clone())); @@ -462,7 +462,7 @@ fn chain_field_names(chain: &[&Class]) -> HashSet { /// name -> (owning class name, method function), first (most-derived) wins — /// matching JS prototype-chain resolution for an exact-class instance. -fn chain_method_map<'a>(chain: &[&'a Class]) -> HashMap { +pub(super) fn chain_method_map<'a>(chain: &[&'a Class]) -> HashMap { let mut out: HashMap = HashMap::new(); for class in chain { for method in &class.methods { @@ -890,7 +890,7 @@ struct ThisStoreRecord<'a> { context: Option<(String, String, Vec)>, } -struct ThisFlowAnalysis<'a, 'b> { +pub(super) struct ThisFlowAnalysis<'a, 'b> { chain: &'b [&'a Class], fields: &'b HashSet, methods: &'b HashMap, @@ -910,6 +910,34 @@ struct ThisFlowAnalysis<'a, 'b> { } impl<'a, 'b> ThisFlowAnalysis<'a, 'b> { + /// A fresh analysis over one class chain. Phase 5a + /// (`collectors/proven_this.rs`) reuses the walk for a method's `this` + /// without the constructor-chain obligations: the receiver of a proven + /// `this` already exists, and its shape is established by the CALL SITE + /// guard (class id + keys token) rather than by in-function provenance. + pub(super) fn new( + chain: &'b [&'a Class], + fields: &'b HashSet, + methods: &'b HashMap, + ) -> Self { + Self { + chain, + fields, + methods, + visited: HashSet::new(), + store_records: Vec::new(), + super_call_args: HashMap::new(), + internally_invoked: HashSet::new(), + } + } + + /// Did the vetted walk observe any `this. = …` store (in this + /// method or anything it transitively invokes on the same `this`)? + /// Phase 5a gates the freeze-family kill on this. + pub(super) fn has_this_store_records(&self) -> bool { + self.store_records.iter().any(|r| r.context.is_some()) + } + /// Walk the constructor chain (self-first `super(...)` order) and every /// chain field initializer under the strict `this` discipline. fn ctor_chain_safe(&mut self) -> bool { @@ -937,7 +965,7 @@ impl<'a, 'b> ThisFlowAnalysis<'a, 'b> { true } - fn method_safe(&mut self, owner: &str, func: &'a perry_hir::Function) -> bool { + pub(super) fn method_safe(&mut self, owner: &str, func: &'a perry_hir::Function) -> bool { self.function_this_safe(owner, &func.name, func) } diff --git a/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs b/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs index 517f558a15..0bc09a679a 100644 --- a/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs +++ b/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs @@ -64,6 +64,21 @@ pub struct ModuleDispatchFacts { /// runtime pollution byte — any such site disables all `Ptr` /// promotion in the module. See `collectors/ptr_numarray.rs`. numarray_prototype_index_barriers: bool, + /// Representation-selection Phase 5a: the module contains at least one + /// `Object.freeze` / `Object.seal` / `Object.preventExtensions` site. + /// + /// This is deliberately NOT part of `shape_barrier_sites`. For a Phase 3b + /// `Ptr` LOCAL the freeze family needs no module-wide kill: the + /// containment walk proves no alias to the object exists at all, and a + /// freeze of the local itself disqualifies it directly + /// (`ptr_shape.rs`'s `Expr::ObjectFreeze` arm). A proven `this` has no + /// such containment — the receiver is owned by the CALLER and is + /// therefore aliased by construction, so `Object.freeze(c)` followed by + /// `c.m()` would let a guard-free raw store silently succeed where the + /// spec requires a strict-mode `TypeError`. Any freeze-family site in the + /// module therefore disables proven-`this` clones **that contain a + /// `this.field` write**; read-only clones are unaffected. + freeze_barrier_sites: bool, } impl Default for ModuleDispatchFacts { @@ -75,6 +90,7 @@ impl Default for ModuleDispatchFacts { opaque_prototype_mutation: true, shape_barrier_sites: true, numarray_prototype_index_barriers: true, + freeze_barrier_sites: true, } } } @@ -124,6 +140,13 @@ impl ModuleDispatchFacts { self.numarray_prototype_index_barriers } + /// Representation-selection Phase 5a: does the module contain any + /// `Object.freeze`/`seal`/`preventExtensions` site? Gates guard-free + /// STORES through a proven `this` (see the field's doc comment). + pub(crate) fn has_freeze_barrier_sites(&self) -> bool { + self.freeze_barrier_sites + } + /// Does the module NAME a prototype object it cannot attribute to a /// declared class (`const p = Array.prototype`, `x.constructor.prototype`, /// …)? Such a reference can be aliased into a local and written through @@ -143,6 +166,7 @@ pub fn collect_module_dispatch_facts(hir: &Module) -> ModuleDispatchFacts { opaque_prototype_mutation: false, shape_barrier_sites: false, numarray_prototype_index_barriers: false, + freeze_barrier_sites: false, }; note_stmts(&hir.init, &mut facts); @@ -188,6 +212,9 @@ fn note_stmts(stmts: &[Stmt], facts: &mut ModuleDispatchFacts) { if super::ptr_numarray::expr_is_numarray_prototype_index_barrier(expr) { facts.numarray_prototype_index_barriers = true; } + if super::proven_this::expr_is_freeze_barrier(expr) { + facts.freeze_barrier_sites = true; + } }); } @@ -200,6 +227,9 @@ fn note_expr_tree(expr: &Expr, facts: &mut ModuleDispatchFacts) { if super::ptr_numarray::expr_is_numarray_prototype_index_barrier(node) { facts.numarray_prototype_index_barriers = true; } + if super::proven_this::expr_is_freeze_barrier(node) { + facts.freeze_barrier_sites = true; + } }); } @@ -378,7 +408,7 @@ fn for_each_expr(expr: &Expr, f: &mut dyn FnMut(&Expr)) { } } -fn for_each_expr_in_stmts(stmts: &[Stmt], f: &mut dyn FnMut(&Expr)) { +pub(super) fn for_each_expr_in_stmts(stmts: &[Stmt], f: &mut dyn FnMut(&Expr)) { for stmt in stmts { for_each_expr_in_stmt(stmt, f); } diff --git a/crates/perry-codegen/src/expr/instance_misc1.rs b/crates/perry-codegen/src/expr/instance_misc1.rs index c11c524aab..20cebc4351 100644 --- a/crates/perry-codegen/src/expr/instance_misc1.rs +++ b/crates/perry-codegen/src/expr/instance_misc1.rs @@ -1406,9 +1406,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // The store keeps the raw-slot plain-finite discipline (an // Inf-crossing update side-exits to the by-name setter, which // performs the layout downgrade the GC scan relies on). - if let Expr::LocalGet(recv_id) = object.as_ref() { - if ctx.repsel_context_allows_canonical_i32 { - let fact = ctx.native_facts.shape_proven_ptr_local(*recv_id).cloned(); + // (Phase 5a's proven `this` never claims numeric fields, so this + // site remains Phase-3b-local-only in practice.) + { + let fact = ctx.ptr_shape_receiver_fact(object.as_ref()).cloned(); + { if let Some(fact) = fact { if fact.numeric_fields.contains(property.as_str()) { if let Some(field_index) = diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 389127e1b4..1d65c813dc 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -770,6 +770,23 @@ pub(crate) struct FnCtx<'a> { /// `PERRY_CANONICAL_I32_LOCALS` env gate. pub repsel_context_allows_canonical_i32: bool, + /// Representation-selection Phase 5a (`collectors/proven_this.rs`): when + /// this body is a `{public}__pshape` method clone, the `Ptr` proof + /// carried by `this`. Consumed by [`FnCtx::ptr_shape_receiver_fact`], which + /// is what makes every `this.field` in the clone lower to the bare + /// fixed-offset form instead of the per-access guard diamond. + /// + /// `None` in every ordinary body — including the PUBLIC method body, which + /// keeps today's guarded lowering because its receiver is unproven. + pub proven_this: Option, + + /// Phase 5a: `(class, method)` pairs with an emitted `__pshape` 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>, + /// Locals referenced anywhere inside a nested closure body (including /// explicit capture lists). Excluded from canonical-i32 selection — the /// capture machinery stays on the boxed protocol. Empty when @@ -1406,6 +1423,32 @@ pub(crate) fn class_field_loop_fact_lookup<'f>( } impl<'a> FnCtx<'a> { + /// The `Ptr` proof for a receiver expression, if any — the single + /// entry point every representation-selection object site consults. + /// + /// * `Expr::LocalGet` — Phase 3b: a shape-proven local + /// (`collectors/ptr_shape.rs`), proven by provenance + containment. + /// * `Expr::This` — Phase 5a: the proven receiver of a `__pshape` method + /// clone (`collectors/proven_this.rs`), proven by the routing call + /// site's class-id + keys-token guard. + /// + /// Both carry the identical storage contract (a shadow-bound, + /// tagged-at-rest NaN-boxed slot), so consumers need no case analysis: + /// re-derive the raw pointer from the slot at every access. + pub(crate) fn ptr_shape_receiver_fact( + &self, + e: &perry_hir::Expr, + ) -> Option<&crate::collectors::PtrShapeLocal> { + if !self.repsel_context_allows_canonical_i32 { + return None; + } + match e { + perry_hir::Expr::LocalGet(id) => self.native_facts.shape_proven_ptr_local(*id), + perry_hir::Expr::This => self.proven_this.as_ref(), + _ => None, + } + } + pub fn next_loop_proof_scope_id(&mut self) -> u32 { let id = self.next_loop_proof_scope_id; self.next_loop_proof_scope_id = self diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index d0833fab57..eb9ad2a6d1 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -1309,15 +1309,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // out of alias analysis) → bitcast → POINTER_MASK → // gep header → gep index → load. No volatile gate, no // header checks, no fallback arm, no phi. - let ptr_shape_fact = match object.as_ref() { - Expr::LocalGet(recv_id) if ctx.repsel_context_allows_canonical_i32 => { - ctx.native_facts - .shape_proven_ptr_local(*recv_id) - .filter(|fact| fact.class_name == class_name) - .cloned() - } - _ => None, - }; + // Phase 5a extends the same proof to `this` inside a + // `__pshape` method clone (collectors/proven_this.rs). + let ptr_shape_fact = ctx + .ptr_shape_receiver_fact(object.as_ref()) + .filter(|fact| fact.class_name == class_name) + .cloned(); if let Some(fact) = ptr_shape_fact { let recv_box = lower_expr(ctx, object)?; let field_idx_str = field_index.to_string(); diff --git a/crates/perry-codegen/src/expr/property_get/helpers.rs b/crates/perry-codegen/src/expr/property_get/helpers.rs index eaffd43bbf..3b40093766 100644 --- a/crates/perry-codegen/src/expr/property_get/helpers.rs +++ b/crates/perry-codegen/src/expr/property_get/helpers.rs @@ -425,14 +425,13 @@ pub(crate) fn lower_raw_f64_class_field_get_for_number_context( // licenses handing the raw load to a number context without the // fallback's `js_number_coerce`; non-numeric-proven fields fall through // to the guarded path below. - let ptr_shape_numeric = match object.as_ref() { - Expr::LocalGet(recv_id) if ctx.repsel_context_allows_canonical_i32 => ctx - .native_facts - .shape_proven_ptr_local(*recv_id) - .map(|fact| fact.class_name == class_name && fact.numeric_fields.contains(property)) - .unwrap_or(false), - _ => false, - }; + // Phase 5a: a proven `this` never claims `numeric_fields` (see + // collectors/proven_this.rs), so this site stays Phase-3b-local-only in + // practice — the shared accessor keeps the two phases in one place. + let ptr_shape_numeric = ctx + .ptr_shape_receiver_fact(object.as_ref()) + .map(|fact| fact.class_name == class_name && fact.numeric_fields.contains(property)) + .unwrap_or(false); if ptr_shape_numeric { let recv_box = lower_expr(ctx, object)?; let field_idx_str = field_index.to_string(); diff --git a/crates/perry-codegen/src/expr/property_set.rs b/crates/perry-codegen/src/expr/property_set.rs index 4a4f684ea2..db99969360 100644 --- a/crates/perry-codegen/src/expr/property_set.rs +++ b/crates/perry-codegen/src/expr/property_set.rs @@ -528,15 +528,17 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // downgrade the GC scan relies on). Boxed slots store // inline with the existing generational write barrier // for possibly-pointer values. - let ptr_shape_proven = match object.as_ref() { - Expr::LocalGet(recv_id) if ctx.repsel_context_allows_canonical_i32 => { - ctx.native_facts - .shape_proven_ptr_local(*recv_id) - .map(|fact| fact.class_name == class_name) - .unwrap_or(false) - } - _ => false, - }; + // Phase 5a routes `this` here too. The freeze-family + // module-wide kill (collectors/proven_this.rs) is what + // makes a guard-free STORE through a proven `this` + // sound: unlike a Phase 3b local the receiver is + // caller-owned and therefore aliased, so a frozen or + // sealed target would otherwise silently accept a raw + // store where the spec requires a strict TypeError. + let ptr_shape_proven = ctx + .ptr_shape_receiver_fact(object.as_ref()) + .map(|fact| fact.class_name == class_name) + .unwrap_or(false); if ptr_shape_proven { let header_skip = crate::target_layout::object_header_size_bytes(ctx.target_triple) diff --git a/crates/perry-codegen/src/lower_call/method_override.rs b/crates/perry-codegen/src/lower_call/method_override.rs index 4da5532f82..74292ac221 100644 --- a/crates/perry-codegen/src/lower_call/method_override.rs +++ b/crates/perry-codegen/src/lower_call/method_override.rs @@ -751,7 +751,35 @@ pub(super) fn emit_guarded_direct_method_call( ); result } else { - ctx.block().call(DOUBLE, direct_fn, direct_arg_slices) + // 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` (the map holds own declarations of + // module-local classes only), 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`). + let pshape_target = ctx + .pshape_methods + .contains_key(&(receiver_class_name.to_string(), property.to_string())) + .then(|| crate::codegen::pshape_method_name(direct_fn)); + let target = pshape_target.as_deref().unwrap_or(direct_fn); + ctx.block().call(DOUBLE, target, direct_arg_slices) } }; 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 dafb87799d..0f1569a0f1 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 @@ -861,14 +861,13 @@ pub(crate) fn try_lower_instance_method_call( // `this`-flow safe), no own-property write can shadow the // method (non-declared-field writes disqualify), and // `prototype_is_stable` held for the chain. - let ptr_shape_receiver = match object { - Expr::LocalGet(recv_id) if ctx.repsel_context_allows_canonical_i32 => ctx - .native_facts - .shape_proven_ptr_local(*recv_id) - .map(|fact| fact.class_name == class_name) - .unwrap_or(false), - _ => false, - }; + // Phase 5a additionally admits `this` inside a `__pshape` + // clone, so a proven receiver's `this.other()` chain stays + // guard-free instead of falling back to the dispatch tower. + let ptr_shape_receiver = ctx + .ptr_shape_receiver_fact(object) + .map(|fact| fact.class_name == class_name) + .unwrap_or(false); if ptr_shape_receiver && !fallback_fn.starts_with("perry_static_") { // Prefer the typed-receiver clone (bare gep+load field // access inside the body) when one exists: the receiver @@ -933,7 +932,20 @@ pub(crate) fn try_lower_instance_method_call( ); return Ok(Some(merged)); } - let direct = ctx.block().call(DOUBLE, &fallback_fn, &arg_slices); + // Representation-selection Phase 5a: route to the + // proven-`this` clone when one exists. The receiver is + // already proven here (no guard is emitted on this path at + // all), and a `pshape_methods` hit additionally proves + // `class_name` DECLARES `property` — so the clone's `this` + // is exactly the class it was compiled for and cannot be a + // subclass instance. Same ABI, so the call is unchanged + // apart from the callee name. + let pshape_target = ctx + .pshape_methods + .contains_key(&(class_name.clone(), property.to_string())) + .then(|| crate::codegen::pshape_method_name(&fallback_fn)); + let target = pshape_target.as_deref().unwrap_or(fallback_fn.as_str()); + let direct = ctx.block().call(DOUBLE, target, &arg_slices); return Ok(Some(direct)); } if let Some(guarded) = emit_guarded_direct_method_call( diff --git a/crates/perry-codegen/src/type_analysis/predicates.rs b/crates/perry-codegen/src/type_analysis/predicates.rs index 0d922e95c4..4e1982e0ae 100644 --- a/crates/perry-codegen/src/type_analysis/predicates.rs +++ b/crates/perry-codegen/src/type_analysis/predicates.rs @@ -244,6 +244,31 @@ pub(crate) fn receiver_is_error_type(ctx: &FnCtx<'_>, e: &Expr) -> bool { false } +/// Does a local's DECLARED type take precedence over its Phase 3b +/// provenance proof? +/// +/// The original rule excluded every `Named(_)` annotation, on the intent that +/// an explicit class annotation should win over the inferred provenance class. +/// But a TypeScript `Named` annotation is just as often an INTERFACE or a type +/// alias — `const o: SomeIface = new C()` — and for those the name is not a +/// class at all: excluding them dropped the `Ptr` proof and sent the +/// access back through by-name get and the dynamic-dispatch tower, even though +/// the provenance proof was perfectly good (and strictly more precise than the +/// interface, which names no layout). +/// +/// Narrowed to "declared `Named(n)` where `n` is a known class": explicit +/// class annotations still win, interface- and alias-annotated locals with +/// `new` provenance keep their guard-free access. `Generic { .. }` keeps its +/// blanket exclusion — the monomorphized-specialization resolution below is +/// what those must go through (#6040). +fn declared_type_overrides_shape_proof(ctx: &FnCtx<'_>, id: &u32) -> bool { + match ctx.local_types.get(id) { + Some(HirType::Named(name)) => ctx.classes.contains_key(name), + Some(HirType::Generic { .. }) => true, + _ => false, + } +} + /// If the expression is a known instance of a Named class type, return /// the class name. Used by the class method dispatch in lower_call to /// pick the right `perry_method__` function. @@ -257,10 +282,8 @@ pub(crate) fn receiver_class_name(ctx: &FnCtx<'_>, e: &Expr) -> Option { // whole lifetime (collectors/ptr_shape.rs), so class-keyed dispatch // (field offsets, method resolution) is authoritative for it. Expr::LocalGet(id) - if !matches!( - ctx.local_types.get(id), - Some(HirType::Named(_)) | Some(HirType::Generic { .. }) - ) && ctx.native_facts.shape_proven_ptr_local(*id).is_some() => + if !declared_type_overrides_shape_proof(ctx, id) + && ctx.native_facts.shape_proven_ptr_local(*id).is_some() => { ctx.native_facts .shape_proven_ptr_local(*id) diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 2c16c93e4c..7131f87493 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -1006,6 +1006,13 @@ fn compute_object_cache_key_with_env( "env_ptr_shape_locals", env_var("PERRY_PTR_SHAPE_LOCALS").as_deref().unwrap_or(""), ); + // Representation-selection Phase 5a — proven-`this` method clones. Off + // removes the `__pshape` bodies and routes both proven call sites back to + // the public guarded body, changing the emitted IR / .o bytes. + h.field( + "env_ptr_shape_this", + env_var("PERRY_PTR_SHAPE_THIS").as_deref().unwrap_or(""), + ); // Representation-selection Phase 4a.3 — Ptr locals: // `=0`/`off`/`false` reverts proven numeric-array locals from guard-free // element access back to the Phase 4a.1/4a.2 guarded tiers, which changes diff --git a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs index 287df764d0..5b0733e451 100644 --- a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs +++ b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs @@ -624,6 +624,7 @@ fn key_changes_with_codegen_env_vars() { "PERRY_SPECIALIZED_ABI_MAX", // Representation-selection Phase 3b: shape-proven Ptr locals. "PERRY_PTR_SHAPE_LOCALS", + "PERRY_PTR_SHAPE_THIS", // Representation-selection Phase 4a.3: Ptr locals. "PERRY_PTR_NUMARRAY_LOCALS", // FEAT_JSCVT single-instruction ToInt32 (apple-arm64). From c43e325432923cb4d79f2798012ec4d97f4fb55b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 28 Jul 2026 16:43:53 +0200 Subject: [PATCH 2/7] =?UTF-8?q?perf(codegen):=20repsel=20Phase=205a=20?= =?UTF-8?q?=E2=80=94=20Ptr=20proven=20`this`=20in=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit an additive internal proven-`this` clone of eligible class methods and route the two call sites that already prove the receiver's exact shape to it, instead of the guard-ridden public body. - collectors/proven_this.rs: eligibility + the freeze-family module-wide kill for write-containing clones (soundness item 1, option (a)). - Separate symbol-reachability ratchet for the clone suffix, rather than widening spec_abi_symbol_reachability's Phase 2 allowlist (soundness item 2). - Guard-free number-context field read under a shape proof without a numeric claim: 2-instruction inline plain-finite check + cold js_number_coerce. - predicates.rs: narrow the Named(_) exclusion to declared class names so interface-annotated locals keep their Phase 3b proof. --- changelog.d/PRNUM-repsel-p5a-proven-this.md | 17 ++ crates/perry-codegen/src/codegen/artifacts.rs | 2 +- crates/perry-codegen/src/codegen/entry.rs | 8 +- crates/perry-codegen/src/codegen/method.rs | 2 +- crates/perry-codegen/src/codegen/mod.rs | 3 +- crates/perry-codegen/src/codegen/opts.rs | 5 +- crates/perry-codegen/src/codegen/typed_abi.rs | 12 - crates/perry-codegen/src/collectors/mod.rs | 2 +- .../src/collectors/proven_this.rs | 55 ++--- .../perry-codegen/src/collectors/ptr_shape.rs | 31 ++- .../src/collectors/scalar_method_dispatch.rs | 1 + crates/perry-codegen/src/expr/mod.rs | 8 +- crates/perry-codegen/src/expr/property_get.rs | 2 +- .../src/expr/property_get/helpers.rs | 103 ++++++++ .../src/lower_call/method_override.rs | 2 +- .../property_get/dynamic_dispatch.rs | 2 +- .../test_gap_repsel_proven_this_frozen.ts | 112 +++++++++ .../test_gap_repsel_ptr_shape_locals.ts | 219 ++++++++++++++++++ 18 files changed, 526 insertions(+), 60 deletions(-) create mode 100644 changelog.d/PRNUM-repsel-p5a-proven-this.md create mode 100644 test-files/test_gap_repsel_proven_this_frozen.ts diff --git a/changelog.d/PRNUM-repsel-p5a-proven-this.md b/changelog.d/PRNUM-repsel-p5a-proven-this.md new file mode 100644 index 0000000000..93326d9065 --- /dev/null +++ b/changelog.d/PRNUM-repsel-p5a-proven-this.md @@ -0,0 +1,17 @@ +**Representation-selection Phase 5a — `Ptr` proven `this` in methods (RFC §5.4/§5.6/§5.7):** two call sites already prove a receiver's exact shape and then throw the proof away by calling the guard-ridden PUBLIC method body, where every `this.field` re-enters the per-access guarded diamond (`expr/class_field_inline_guard.rs`) that LLVM provably cannot hoist (volatile gate + the fallback arm's opaque call block LICM). Phase 5a emits an additive `internal` proven-`this` clone of the method and routes both sites to it: `lower_call/method_override.rs`'s `method_direct.fast` arm (entered only after `js_method_direct_shape_guard` / `js_typed_feedback_method_direct_call_guard` matched class id **and** keys token) and `lower_call/property_get/dynamic_dispatch.rs`'s Phase-3b guard-free `Ptr` receiver arm. Net new proof work, GC work and layout work: zero — the clone keeps the identical `(double this, double args…)` ABI and the identical shadow-bound tagged-at-rest receiver slot (`GC_TYPE_OBJECT` is movable, so the `TaPtr` no-shadow shortcut does **not** transfer), and field indexes come from the existing chain-global `class_field_global_index`. + +Structural result (`test_gap_repsel_ptr_shape_locals.ts`, pre-opt → clone; verified to persist post-`opt -O3`): `Vec5a.lenSq` 4 volatile gates / 4 `js_typed_feedback_class_field_get_guard` / 4 by-name gets (297 lines) → **0 / 0 / 0** (97 lines); `Vec5a.normalizeTo` 5 / 7 / 5 (786 lines) → **0 / 0 / 0** (489); `Counter5a.increment` and `.bump` 1 / 1 / 1 → **0 / 0 / 0**. The generic bodies retain every diamond post-`-O3`, which is the direct evidence that the per-access cost is unhoistable and has to be removed structurally. Small clones are then fully inlined into their call sites by LLVM (they are `internal` with one caller). + +Eligibility (class level): the Phase 3b `chain_admissible` walk (modeled, accessor-free, computed-free, statically-extended chain), `ModuleDispatchFacts::prototype_is_stable`, and the module-wide §5.2 shape-barrier kill. Method level: not async/generator/`was_plain_async`, no captures, no rest/default/`arguments` params, `this` never used as a value (reuses `ThisFlowAnalysis` — `Expr::This` in value position and closures mentioning `this` both reject), every `this.f = v` write to a declared chain field, and every internally-invoked `this.m()`/`super.m()` vetted transitively. **Subclass safety is enforced at the routing sites, not in the collector:** both only route when the receiver's proven exact class *declares* the method (`method_registry.rs` never inserts inherited entries), so the clone's `this` is exactly the class it was compiled for; inherited dispatch keeps today's lowering. + +`numeric_fields` is deliberately **not** claimed for a proven `this`. A Phase 3b local can claim `JsNumber` under an exhaustive reachable-store proof because containment proves no alias exists; a proven `this` is caller-owned and aliased by construction, so an `obj.f = "s"` store elsewhere — which downgrades the slot's raw-f64 layout at runtime — is not enumerable. Instead the number context keeps a 2-instruction inline plain-finite check on the *loaded bits* with a cold `js_number_coerce` arm, which still retires the volatile gate, the 7-header-load shape check, the guard call and the by-name fallback lookup. + +**`Object.freeze`/`seal`/`preventExtensions` (soundness item 1) — took option (a), the module-wide kill for write-containing clones.** These are not in the §5.2 barrier list; Phase 3b needs no rule there because containment proves no alias and a freeze of the local disqualifies it directly. A proven `this` has no containment, so `Object.freeze(c)` followed by `c.m()` would let a guard-free raw store silently succeed where strict-mode class code must throw a `TypeError`. A new `ModuleDispatchFacts::freeze_barrier_sites` fact therefore disables proven-`this` clones **that contain a `this.field` write** whenever the module contains any freeze-family site; read-only clones and all of Phase 3b are unaffected. Option (b) (reads-only first increment) was rejected because it would have excluded the single most common method shape (`this.value = this.value + 1`). Covered by a dedicated gap test (`test_gap_repsel_proven_this_frozen.ts` — separate file precisely because the kill is module-wide). + +**Vtable reachability (soundness item 2):** the clone is `internal` and is never passed to `js_register_class_method`, so it has no indirect route via `js_native_call_method`, `emit_collapsed_instance_dispatch`, the own-property override probe, or `.call`/`.apply`/`.bind`. A **separate** ratchet test (`collectors::proven_this::tests::pshape_symbol_reachability`) pins a four-file allowlist for the symbol fragment rather than widening `spec_abi_symbol_reachability`'s allowlist, which would have un-ratcheted the Phase 2 guarantee. + +Two deliberate deviations from the plan, both documented inline: (1) the per-field `js_typed_feedback_class_field_get_guard` loop at the guarded call site is **kept**, because 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 class id and key set while downgrading the slot layout; the proven-`this` clone needs no such guard because it never claims `JsNumber`. (2) the typed-receiver arm keeps priority over the new routing at the guard-free site, to avoid regressing an already-tuned path. + +Also included, the adjacent `type_analysis/predicates.rs` fix: the `Named(_)` exclusion that made `const o: SomeIface = new C()` lose its Phase 3b proof is narrowed to "declared `Named(n)` where `n` is a known class". A TS `Named` annotation is just as often an interface or alias, which names no layout and is strictly weaker than the provenance proof; explicit class annotations still win, and `Generic { .. }` keeps its blanket exclusion (#6040). + +Gated by `PERRY_PTR_SHAPE_THIS` (default on, `0`/`off`/`false` disables), keyed into the object cache. diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index e13f57c419..015fadd153 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -419,7 +419,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { ) .with_context(|| format!("lowering method '{}::{}'", class.name, method.name))?; // Representation-selection Phase 5a: the additive `internal` - // `{public}__pshape` clone. Same HIR, same ABI, same shadow-bound + // 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 diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 2b9596dd76..a2a63b3094 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -820,8 +820,8 @@ pub(super) fn compile_module_entry( typed_i1_functions: &cross_module.typed_i1_functions, 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, - proven_this: None, + pshape_methods: &cross_module.pshape_methods, + proven_this: None, typed_i32_methods: &cross_module.typed_i32_methods, typed_i1_methods: &cross_module.typed_i1_methods, typed_string_methods: &cross_module.typed_string_methods, @@ -1439,8 +1439,8 @@ pub(super) fn compile_module_entry( typed_i1_functions: &cross_module.typed_i1_functions, 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, - proven_this: None, + pshape_methods: &cross_module.pshape_methods, + proven_this: None, 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/method.rs b/crates/perry-codegen/src/codegen/method.rs index 51dd613ea7..f46cc86e79 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -280,7 +280,7 @@ pub(super) fn compile_method( // primary (`proven_this: None`) invocation for this same method. let is_pshape_clone = proven_this.is_some(); let llvm_name = if is_pshape_clone { - super::pshape_method_name(&public_llvm_name) + crate::collectors::pshape_method_name(&public_llvm_name) } else if typed_public_trampoline.is_some() || force_generic_body { generic_method_body_name(&public_llvm_name) } else { diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index bf38829fd1..003039a949 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -72,8 +72,7 @@ pub(crate) use opts::{CrossModuleCtx, ImportedCtor}; pub(crate) use spec_abi::{spec_abi_enabled, spec_function_name, SpecDispatch, SpecFnPlan}; pub(crate) use typed_abi::{ emit_typed_arg_guard, emit_typed_arg_to_raw, generic_closure_body_name, - generic_function_body_name, generic_method_body_name, pshape_method_name, - typed_f64_closure_name, + generic_function_body_name, generic_method_body_name, typed_f64_closure_name, typed_f64_function_name, typed_f64_method_name, typed_f64_receiver_method_info, typed_f64_receiver_method_name, typed_i1_closure_name, typed_i1_function_name, typed_i1_method_name, typed_i32_closure_name, typed_i32_function_name, typed_i32_method_name, diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index a45e4ea2c6..7528b1cf9c 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -798,13 +798,14 @@ pub(crate) struct CrossModuleCtx { pub typed_f64_receiver_methods: std::collections::HashMap<(String, String), super::typed_abi::TypedReceiverMethodInfo>, /// Representation-selection Phase 5a: `(class, method)` pairs that have a - /// generated `internal` `{public}__pshape` clone whose `this` is proven + /// generated `internal` proven-`this` clone /// (`collectors/proven_this.rs`). Keys are OWN declarations of /// module-local classes only, which is precisely the condition the two /// routing sites rely on: a hit means the receiver's proven exact class is /// the class the clone was compiled for, so `this` cannot be a subclass /// instance with a different chain. - pub pshape_methods: std::collections::HashMap<(String, String), crate::collectors::PtrShapeLocal>, + pub pshape_methods: + std::collections::HashMap<(String, String), crate::collectors::PtrShapeLocal>, /// Inline closure bodies that have a generated internal typed-f64 clone. /// Only statically-known local closure calls may select these clones after /// closure identity/arity and numeric argument guards pass. diff --git a/crates/perry-codegen/src/codegen/typed_abi.rs b/crates/perry-codegen/src/codegen/typed_abi.rs index 0f971d1b5e..88c9172004 100644 --- a/crates/perry-codegen/src/codegen/typed_abi.rs +++ b/crates/perry-codegen/src/codegen/typed_abi.rs @@ -319,18 +319,6 @@ pub(crate) fn generic_method_body_name(generic_name: &str) -> String { format!("{generic_name}__generic") } -/// Representation-selection Phase 5a: the `internal` proven-`this` clone of a -/// method (`collectors/proven_this.rs`). Same `(double this, double args…)` -/// ABI as the public symbol — only the body's `this.field` lowering differs. -/// -/// This symbol is NEVER registered into a runtime vtable -/// (`js_register_class_method` keeps the public name) and is reachable only -/// from the two proven call sites. `pshape_symbol_reachability` in -/// `collectors/proven_this.rs` ratchets that. -pub(crate) fn pshape_method_name(generic_name: &str) -> String { - format!("{generic_name}__pshape") -} - pub(crate) fn generic_closure_body_name(generic_name: &str) -> String { format!("{generic_name}__generic") } diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index fe471a24c4..6bb3dcb36b 100644 --- a/crates/perry-codegen/src/collectors/mod.rs +++ b/crates/perry-codegen/src/collectors/mod.rs @@ -63,8 +63,8 @@ pub(crate) use integer_locals::{ pub(crate) use local_refs::{expr_contains_local_get, mark_all_candidate_refs_in_expr}; pub(crate) use mutation::has_any_mutation; pub(crate) use pointer_locals::collect_pointer_typed_locals; +pub(crate) use proven_this::{method_proven_this, pshape_method_name}; pub(crate) use ptr_numarray::{NumArrayDensity, NumArrayLocal}; -pub(crate) use proven_this::method_proven_this; pub(crate) use ptr_shape::PtrShapeLocal; pub(crate) use refs::{ collect_let_ids, collect_ref_ids_in_expr, collect_ref_ids_in_stmts, is_clamp_call, diff --git a/crates/perry-codegen/src/collectors/proven_this.rs b/crates/perry-codegen/src/collectors/proven_this.rs index 3733b2351a..54ed84ba66 100644 --- a/crates/perry-codegen/src/collectors/proven_this.rs +++ b/crates/perry-codegen/src/collectors/proven_this.rs @@ -58,7 +58,7 @@ use std::collections::{HashMap, HashSet}; use perry_hir::{Class, Expr, Function}; use super::ptr_shape::{ - chain_admissible, chain_field_names, chain_method_map, chain_classes, ptr_shape_locals_enabled, + chain_admissible, chain_classes, chain_field_names, chain_method_map, ptr_shape_locals_enabled, PtrShapeLocal, ThisFlowAnalysis, }; use super::ModuleDispatchFacts; @@ -77,6 +77,18 @@ pub fn ptr_shape_this_enabled() -> bool { }) } +/// The `internal` proven-`this` clone symbol of a method. Same +/// `(double this, double args…)` ABI as the public symbol — only the body's +/// `this.field` lowering differs. +/// +/// This symbol is NEVER registered into a runtime vtable +/// (`js_register_class_method` keeps the public name) and is reachable only +/// from the two proven call sites. [`tests::pshape_symbol_reachability`] +/// ratchets that. +pub(crate) fn pshape_method_name(public_name: &str) -> String { + format!("{public_name}__pshape") +} + /// The `Object.freeze` / `Object.seal` / `Object.preventExtensions` family. /// /// Deliberately NOT part of [`super::ptr_shape::expr_is_shape_barrier`] — see @@ -158,8 +170,10 @@ pub(crate) fn method_proven_this( // The clone must actually remove work: a method that never touches a // declared field through `this` lowers identically to the public body and - // would be pure code-size bloat. - if !method_reads_or_writes_chain_field(method, &fields) { + // would be pure code-size bloat. Writes are counted through the store + // records (which cover every HIR store form — `PropertySet`, + // `PropertyUpdate`, `PutValueSet`), reads through the body walk. + if !writes_fields && !method_reads_chain_field(method, &fields) { return None; } @@ -170,29 +184,18 @@ pub(crate) fn method_proven_this( }) } -/// Does the method body mention `this.` at all? -fn method_reads_or_writes_chain_field(method: &Function, fields: &HashSet) -> bool { +/// Does the method body READ `this.` anywhere? +fn method_reads_chain_field(method: &Function, fields: &HashSet) -> bool { let mut found = false; super::scalar_method_dispatch::for_each_expr_in_stmts(&method.body, &mut |e| { if found { return; } - let property = match e { - Expr::PropertyGet { - object, property, .. - } if matches!(object.as_ref(), Expr::This) => { - Some(property) - } - Expr::PropertySet { - object, property, .. - } - | Expr::PropertyUpdate { - object, property, .. - } if matches!(object.as_ref(), Expr::This) => Some(property), - _ => None, - }; - if let Some(property) = property { - if fields.contains(property.as_str()) { + if let Expr::PropertyGet { + object, property, .. + } = e + { + if matches!(object.as_ref(), Expr::This) && fields.contains(property.as_str()) { found = true; } } @@ -232,11 +235,11 @@ mod tests { // (which emits `js_register_class_method`) is deliberately ABSENT: // the vtable must only ever hold the public symbol. let allowed: [&str; 6] = [ - "collectors/proven_this.rs", // this test - "codegen/typed_abi.rs", // name helper - "codegen/method.rs", // clone emission - "codegen/artifacts.rs", // emission driver - "lower_call/method_override.rs", // guarded fast-arm routing + "collectors/proven_this.rs", // this test + "codegen/typed_abi.rs", // name helper + "codegen/method.rs", // clone emission + "codegen/artifacts.rs", // emission driver + "lower_call/method_override.rs", // guarded fast-arm routing "lower_call/property_get/dynamic_dispatch.rs", // guard-free routing ]; let mut offenders: Vec = Vec::new(); diff --git a/crates/perry-codegen/src/collectors/ptr_shape.rs b/crates/perry-codegen/src/collectors/ptr_shape.rs index e439372779..76af050c79 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape.rs @@ -279,6 +279,7 @@ pub(crate) fn collect_shape_proven_ptr_locals( store_records: Vec::new(), super_call_args: HashMap::new(), internally_invoked: HashSet::new(), + allow_this_in_store_values: false, }; if !analysis.ctor_chain_safe() { continue; @@ -401,7 +402,10 @@ fn collect_alias_edges(stmts: &[Stmt], out: &mut Vec<(u32, u32)>) { /// The chain (self first) when every link is a modeled, accessor-free, /// computed-free, statically-extended user class; `None`-equivalent (empty) /// otherwise. -pub(super) fn chain_classes<'a>(classes: &HashMap, class_name: &str) -> Vec<&'a Class> { +pub(super) fn chain_classes<'a>( + classes: &HashMap, + class_name: &str, +) -> Vec<&'a Class> { let mut out = Vec::new(); let mut current = Some(class_name.to_string()); let mut seen = HashSet::new(); @@ -462,7 +466,9 @@ pub(super) fn chain_field_names(chain: &[&Class]) -> HashSet { /// name -> (owning class name, method function), first (most-derived) wins — /// matching JS prototype-chain resolution for an exact-class instance. -pub(super) fn chain_method_map<'a>(chain: &[&'a Class]) -> HashMap { +pub(super) fn chain_method_map<'a>( + chain: &[&'a Class], +) -> HashMap { let mut out: HashMap = HashMap::new(); for class in chain { for method in &class.methods { @@ -907,6 +913,18 @@ pub(super) struct ThisFlowAnalysis<'a, 'b> { /// so parameters of internally-invoked methods must stay unproven even /// when every EXTERNAL call site passes numeric arguments. internally_invoked: HashSet, + /// Phase 5a only: permit a `this.f = ` store. + /// + /// Phase 3b rejects those because its numeric-field proof resolves store + /// VALUES through constructor/method call-site arguments, and a + /// `this`-dependent value cannot be resolved that way — so the store must + /// not be recorded as provably-numeric. Phase 5a claims no numeric fields + /// at all (`collectors/proven_this.rs`), so the restriction buys it + /// nothing while excluding the single most common method shape there is: + /// `this.value = this.value + 1`. Safety is unaffected — the value + /// expression still goes through `expr_this_safe`, which rejects `this` + /// in value position and admits only declared-chain `this.field` reads. + allow_this_in_store_values: bool, } impl<'a, 'b> ThisFlowAnalysis<'a, 'b> { @@ -928,6 +946,7 @@ impl<'a, 'b> ThisFlowAnalysis<'a, 'b> { store_records: Vec::new(), super_call_args: HashMap::new(), internally_invoked: HashSet::new(), + allow_this_in_store_values: true, } } @@ -1100,7 +1119,9 @@ impl<'a, 'b> ThisFlowAnalysis<'a, 'b> { property, value, } if matches!(object.as_ref(), Expr::This) => { - if !self.fields.contains(property) || expr_mentions_this(value) { + if !self.fields.contains(property) + || (!self.allow_this_in_store_values && expr_mentions_this(value)) + { return false; } self.store_records.push(ThisStoreRecord { @@ -1135,7 +1156,9 @@ impl<'a, 'b> ThisFlowAnalysis<'a, 'b> { let Expr::String(property) = key.as_ref() else { return false; }; - if !self.fields.contains(property) || expr_mentions_this(value) { + if !self.fields.contains(property) + || (!self.allow_this_in_store_values && expr_mentions_this(value)) + { return false; } self.store_records.push(ThisStoreRecord { diff --git a/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs b/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs index 0bc09a679a..191ca2c860 100644 --- a/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs +++ b/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs @@ -613,6 +613,7 @@ mod tests { opaque_prototype_mutation: false, shape_barrier_sites: false, numarray_prototype_index_barriers: false, + freeze_barrier_sites: false, } } diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 1d65c813dc..15f638138a 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -771,7 +771,7 @@ pub(crate) struct FnCtx<'a> { pub repsel_context_allows_canonical_i32: bool, /// Representation-selection Phase 5a (`collectors/proven_this.rs`): when - /// this body is a `{public}__pshape` method clone, the `Ptr` proof + /// this body is a proven-`this` method clone, the `Ptr` proof /// carried by `this`. Consumed by [`FnCtx::ptr_shape_receiver_fact`], which /// is what makes every `this.field` in the clone lower to the bare /// fixed-offset form instead of the per-access guard diamond. @@ -780,7 +780,7 @@ pub(crate) struct FnCtx<'a> { /// keeps today's guarded lowering because its receiver is unproven. pub proven_this: Option, - /// Phase 5a: `(class, method)` pairs with an emitted `__pshape` clone. + /// 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`. @@ -1428,8 +1428,8 @@ impl<'a> FnCtx<'a> { /// /// * `Expr::LocalGet` — Phase 3b: a shape-proven local /// (`collectors/ptr_shape.rs`), proven by provenance + containment. - /// * `Expr::This` — Phase 5a: the proven receiver of a `__pshape` method - /// clone (`collectors/proven_this.rs`), proven by the routing call + /// * `Expr::This` — Phase 5a: the proven receiver of a proven-`this` + /// method clone (`collectors/proven_this.rs`), proven by the routing call /// site's class-id + keys-token guard. /// /// Both carry the identical storage contract (a shadow-bound, diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index eb9ad2a6d1..c44df691a4 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -1310,7 +1310,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // gep header → gep index → load. No volatile gate, no // header checks, no fallback arm, no phi. // Phase 5a extends the same proof to `this` inside a - // `__pshape` method clone (collectors/proven_this.rs). + // proven-`this` method clone (collectors/proven_this.rs). let ptr_shape_fact = ctx .ptr_shape_receiver_fact(object.as_ref()) .filter(|fact| fact.class_name == class_name) diff --git a/crates/perry-codegen/src/expr/property_get/helpers.rs b/crates/perry-codegen/src/expr/property_get/helpers.rs index 3b40093766..bbc8722b6e 100644 --- a/crates/perry-codegen/src/expr/property_get/helpers.rs +++ b/crates/perry-codegen/src/expr/property_get/helpers.rs @@ -483,6 +483,109 @@ pub(crate) fn lower_raw_f64_class_field_get_for_number_context( return Ok(Some(val)); } + // Representation-selection Phase 5a: the receiver's SHAPE is proven but + // the field is not numeric-proven (always the case for a proven `this` — + // see `collectors/proven_this.rs`: the receiver is caller-owned and + // therefore aliased, so no exhaustive-store proof is available). + // + // The shape proof alone already retires the entire guard diamond: no + // volatile gate, no 7-header-load shape check, no + // `js_typed_feedback_class_field_get_guard` call, and no by-name + // `js_object_get_field_by_name_f64` fallback — the proof says this slot IS + // the field, so a bare fixed-offset load always yields the right VALUE. + // What is NOT proven is the value's TYPE: an aliased `obj.f = "s"` store + // elsewhere downgrades the slot's raw-f64 layout, and a guard-free read + // cannot consult that layout. So the number context keeps a 2-instruction + // inline plain-finite check on the LOADED BITS (`and` + `icmp`, no call, + // no header load) with a cold `js_number_coerce` arm — exactly the + // ToNumber the guarded fallback performs, minus the lookup. + let ptr_shape_proven_shape = ctx + .ptr_shape_receiver_fact(object.as_ref()) + .map(|fact| fact.class_name == class_name) + .unwrap_or(false); + if ptr_shape_proven_shape { + let recv_box = lower_expr(ctx, object)?; + let field_idx_str = field_index.to_string(); + let header_skip = + crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); + let (val_raw, is_plain) = { + let blk = ctx.block(); + let obj_bits = blk.bitcast_double_to_i64(&recv_box); + let obj_handle = blk.and(I64, &obj_bits, POINTER_MASK_I64); + let obj_ptr = blk.inttoptr(I64, &obj_handle); + let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); + let field_ptr = blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]); + let val_raw = blk.load(DOUBLE, &field_ptr); + let val_bits = blk.bitcast_double_to_i64(&val_raw); + let is_plain = crate::expr::class_field_inline_guard::emit_plain_finite_number_check( + blk, &val_bits, + ); + (val_raw, is_plain) + }; + let fast_idx = ctx.new_block("ptr_shape_get_number.plain"); + let coerce_idx = ctx.new_block("ptr_shape_get_number.coerce"); + let merge_idx = ctx.new_block("ptr_shape_get_number.merge"); + let fast_label = ctx.block_label(fast_idx); + let coerce_label = ctx.block_label(coerce_idx); + let merge_label = ctx.block_label(merge_idx); + ctx.block().cond_br(&is_plain, &fast_label, &coerce_label); + + ctx.current_block = fast_idx; + let fast_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = coerce_idx; + let coerced = ctx + .block() + .call(DOUBLE, "js_number_coerce", &[(DOUBLE, &val_raw)]); + let coerce_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = merge_idx; + let merged = ctx + .block() + .phi(DOUBLE, &[(&val_raw, &fast_end), (&coerced, &coerce_end)]); + let lowered = LoweredValue { + semantic: SemanticKind::JsNumber, + rep: NativeRep::F64, + llvm_ty: DOUBLE, + value: merged.clone(), + }; + ctx.record_lowered_value_with_access_mode_and_facts( + "ClassFieldGet", + None, + "class_field_get_number.shape_proven_checked_load", + &lowered, + Some(BoundsState::Guarded { + guard_id: "ptr_shape_static_proof".to_string(), + }), + None, + Some(BufferAccessMode::CheckedNative), + None, + None, + None, + vec![raw_f64_layout_fact( + None, + "consumed", + "ptr_shape_static_proof", + None, + )], + Vec::new(), + false, + false, + vec![ + format!("class={}", class_name), + format!("field={}", property), + format!("field_index={}", field_idx_str), + "receiver_proof=ptr_shape_static_proof".to_string(), + "numeric_proven=false".to_string(), + "value_check=inline_plain_finite".to_string(), + "number_context=true".to_string(), + ], + ); + return Ok(Some(merged)); + } + let recv_box = lower_expr(ctx, object)?; let key_idx = ctx.strings.intern(property); let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); diff --git a/crates/perry-codegen/src/lower_call/method_override.rs b/crates/perry-codegen/src/lower_call/method_override.rs index 74292ac221..0fb76106b1 100644 --- a/crates/perry-codegen/src/lower_call/method_override.rs +++ b/crates/perry-codegen/src/lower_call/method_override.rs @@ -777,7 +777,7 @@ pub(super) fn emit_guarded_direct_method_call( let pshape_target = ctx .pshape_methods .contains_key(&(receiver_class_name.to_string(), property.to_string())) - .then(|| crate::codegen::pshape_method_name(direct_fn)); + .then(|| crate::collectors::pshape_method_name(direct_fn)); let target = pshape_target.as_deref().unwrap_or(direct_fn); ctx.block().call(DOUBLE, target, direct_arg_slices) } diff --git a/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs b/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs index 0f1569a0f1..ee6d96d104 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 @@ -943,7 +943,7 @@ pub(crate) fn try_lower_instance_method_call( let pshape_target = ctx .pshape_methods .contains_key(&(class_name.clone(), property.to_string())) - .then(|| crate::codegen::pshape_method_name(&fallback_fn)); + .then(|| crate::collectors::pshape_method_name(&fallback_fn)); let target = pshape_target.as_deref().unwrap_or(fallback_fn.as_str()); let direct = ctx.block().call(DOUBLE, target, &arg_slices); return Ok(Some(direct)); diff --git a/test-files/test_gap_repsel_proven_this_frozen.ts b/test-files/test_gap_repsel_proven_this_frozen.ts new file mode 100644 index 0000000000..e58b11e6d6 --- /dev/null +++ b/test-files/test_gap_repsel_proven_this_frozen.ts @@ -0,0 +1,112 @@ +// Representation-selection Phase 5a soundness: `Object.freeze` / `seal` / +// `preventExtensions` vs. a proven `this` (PERRY_PTR_SHAPE_THIS, RFC +// docs/representation-selection-rfc.md §5.2/§5.7). +// +// Why this file is separate from test_gap_repsel_ptr_shape_locals.ts: the +// freeze-family kill is MODULE-WIDE. A Phase 3b `Ptr` local needs no +// such rule — its containment walk proves no alias to the object exists, and +// freezing the local itself disqualifies it directly. A proven `this` has no +// containment: the receiver is owned by the CALLER and is therefore aliased by +// construction, so `Object.freeze(c)` followed by `c.m()` would let a +// guard-free raw store silently succeed where the spec requires a strict-mode +// TypeError. Any freeze-family site in the module therefore disables +// proven-`this` clones that contain a `this.field` WRITE; read-only clones are +// unaffected. +// +// Class methods are strict-mode code, so a write to a frozen own property +// THROWS rather than silently no-opping. That is the observable this file +// pins byte-for-byte against Node. + +class Frozen { + value: number; + label: string; + constructor(value: number, label: string) { + this.value = value; + this.label = label; + } + // WRITE-containing method: the clone for this one is what the freeze kill + // must suppress. + bump(by: number): number { + this.value = this.value + by; + return this.value; + } + // READ-only method: always safe — a frozen object reads back exactly the + // same slot bits. + read(): number { + return this.value * 2; + } + describe(): string { + return this.label + "=" + this.value; + } +} + +// 1. Un-frozen receiver: writes land normally. +const live = new Frozen(10, "live"); +console.log("live:" + live.bump(5) + ":" + live.read() + ":" + live.describe()); + +// 2. Frozen receiver, strict-mode method body: the write MUST throw a +// TypeError. A guard-free raw store would have silently succeeded. +const frost = new Frozen(1, "frost"); +Object.freeze(frost); +console.log("frozen-read:" + frost.read() + ":" + frost.describe()); +try { + frost.bump(1); + console.log("frozen-write: NO THROW (wrong)"); +} catch (e) { + console.log("frozen-write:" + (e instanceof TypeError) + ":" + (e as Error).name); +} +// The value must be unchanged after the failed write. +console.log("frozen-after:" + frost.value + ":" + frost.read()); + +// 3. Frozen through an ALIAS — the shape the containment walk cannot see. +// `alias` and `target` are the same object; freezing through one must be +// observed by a method call through the other. +const target = new Frozen(100, "t"); +const alias = target; +Object.freeze(alias); +try { + target.bump(7); + console.log("alias-write: NO THROW (wrong)"); +} catch (e) { + console.log("alias-write:" + (e instanceof TypeError)); +} +console.log("alias-after:" + target.value + ":" + alias.value); + +// 4. `Object.seal` — existing properties stay writable, so the write lands; +// only additions are rejected. Reads and writes must both be exact. +const sealed = new Frozen(3, "s"); +Object.seal(sealed); +console.log("sealed-write:" + sealed.bump(4) + ":" + sealed.describe()); +console.log("sealed-frozen:" + Object.isFrozen(sealed) + ":" + Object.isSealed(sealed)); + +// 5. `Object.preventExtensions` — same: existing fields remain writable. +const noext = new Frozen(20, "n"); +Object.preventExtensions(noext); +console.log("noext-write:" + noext.bump(2) + ":" + Object.isExtensible(noext)); + +// 6. A hot loop over a frozen receiver's READ-only method: this is the path +// that stays guard-free even under the module-wide write kill. +function readLoop(n: number): number { + let acc = 0; + for (let i = 0; i < n; i++) { + acc += frost.read(); + } + return acc; +} +console.log("read-loop:" + readLoop(1000)); + +// 7. Freeze AFTER a hot write loop: the clone (if any) and the guarded path +// must agree on the final value, and the post-freeze write must throw. +const late = new Frozen(0, "late"); +for (let i = 0; i < 500; i++) { + late.bump(1); +} +console.log("late-before:" + late.value); +Object.freeze(late); +try { + late.bump(1); + console.log("late-write: NO THROW (wrong)"); +} catch (e) { + console.log("late-write:" + (e instanceof TypeError)); +} +console.log("late-after:" + late.value + ":" + late.describe()); diff --git a/test-files/test_gap_repsel_ptr_shape_locals.ts b/test-files/test_gap_repsel_ptr_shape_locals.ts index 1a24b6b2d4..00b42d286d 100644 --- a/test-files/test_gap_repsel_ptr_shape_locals.ts +++ b/test-files/test_gap_repsel_ptr_shape_locals.ts @@ -214,3 +214,222 @@ function internalPoison(): string { return acc + ":" + t + ":" + after + ":" + typeof after; } console.log(internalPoison()); + +// ───────────────────────────────────────────────────────────────────────── +// Representation-selection Phase 5a: proven `this` in methods +// (PERRY_PTR_SHAPE_THIS). Both call sites that already prove a receiver's +// exact shape route to the internal `__pshape` clone, whose `this.field` +// accesses lower guard-free. The frozen-receiver case lives in its own file +// (test_gap_repsel_proven_this_frozen.ts) because the freeze-family kill is +// module-wide by construction — a single Object.freeze here would disable +// every write-containing clone in THIS file and hide the cases below. +// ───────────────────────────────────────────────────────────────────────── + +// 12. The 09_method_calls shape: a module-scope receiver (NOT a Phase 3b +// local — module globals are excluded), so the call goes through the +// guarded `method_direct.fast` arm. That guard proves class id + keys +// token, and Phase 5a routes it to the proven-`this` clone. +class Counter5a { + value: number; + constructor() { + this.value = 0; + } + increment(): void { + this.value = this.value + 1; + } + bump(by: number): void { + this.value = this.value + by; + } + get(): number { + return this.value; + } +} +const counter5a = new Counter5a(); +for (let i = 0; i < 2000; i++) { + counter5a.increment(); +} +counter5a.bump(1.5); +console.log("p5a-counter:" + counter5a.get() + ":" + typeof counter5a.get()); + +// 13. Proven `this` read + write + a `this.m()` method chain, reached from a +// Phase 3b local (the guard-free routing site). +class Vec5a { + x: number; + y: number; + scale: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + this.scale = 1; + } + lenSq(): number { + return this.x * this.x + this.y * this.y; + } + // `this.lenSq()` is an internal method chain: vetted transitively. + normalizeTo(target: number): number { + const l = this.lenSq(); + this.scale = target / (l === 0 ? 1 : l); + this.x = this.x * this.scale; + this.y = this.y * this.scale; + return this.scale; + } + describe(): string { + return this.x + "," + this.y + "," + this.scale; + } +} +function provenThisChain(n: number): string { + const v = new Vec5a(3, 4); + let acc = 0; + for (let i = 0; i < n; i++) { + acc += v.lenSq(); + } + const s = v.normalizeTo(2); + return acc + "|" + s + "|" + v.describe(); +} +console.log("p5a-chain:" + provenThisChain(500)); + +// 14. `this` ESCAPE rejection: a method that hands `this` out as a value can +// alias the receiver, so no clone may be emitted and every access must +// keep the guarded lowering. Output must be byte-exact either way. +const escaped5a: any[] = []; +class Leaky5a { + n: number; + constructor(n: number) { + this.n = n; + } + leak(): void { + escaped5a.push(this); // `this` in value position — disqualifies + this.n = this.n + 1; + } + read(): number { + return this.n; + } +} +function leakRun(): string { + const l = new Leaky5a(7); + l.leak(); + l.leak(); + // The alias observes the same object identity and the same mutations. + const same = escaped5a[0] === escaped5a[1] && escaped5a[0] === l; + (escaped5a[0] as Leaky5a).n = 99; + return l.read() + ":" + same + ":" + escaped5a.length; +} +console.log("p5a-leak:" + leakRun()); + +// 15. Closure-capturing-`this` rejection: an arrow function in the body +// captures `this`, which creates an alias the walk cannot follow. +class Closed5a { + v: number; + constructor(v: number) { + this.v = v; + } + addAll(xs: number[]): number { + xs.forEach((x) => { + this.v = this.v + x; // captures_this — disqualifies the clone + }); + return this.v; + } +} +function closureThis(): string { + const c = new Closed5a(10); + const r = c.addAll([1, 2, 3, 4]); + return r + ":" + c.v; +} +console.log("p5a-closure:" + closureThis()); + +// 16. Static-method exclusion: statics have no receiver at all, and the +// `perry_static_` targets must never be routed to a `__pshape` symbol. +class Stat5a { + static base: number = 5; + static twice(x: number): number { + return x * 2 + Stat5a.base; + } + inst: number; + constructor() { + this.inst = Stat5a.twice(3); + } + read(): number { + return this.inst + Stat5a.twice(1); + } +} +function staticRun(): string { + const s = new Stat5a(); + return Stat5a.twice(10) + ":" + s.read() + ":" + s.inst; +} +console.log("p5a-static:" + staticRun()); + +// 17. Interface-annotated local with `new` provenance (the predicates.rs +// narrowing): `const o: Shaped5a = new Impl5a()` must KEEP its Ptr +// proof — `Shaped5a` names no class, so the declared type carries +// strictly less information than the provenance. +interface Shaped5a { + w: number; + area(): number; +} +class Impl5a implements Shaped5a { + w: number; + h: number; + constructor(w: number, h: number) { + this.w = w; + this.h = h; + } + area(): number { + return this.w * this.h; + } +} +function ifaceLocal(n: number): string { + const o: Shaped5a = new Impl5a(3, 4); + let acc = 0; + for (let i = 0; i < n; i++) { + acc += o.area() + o.w; + } + return acc + ":" + o.w + ":" + o.area(); +} +console.log("p5a-iface:" + ifaceLocal(250)); + +// 18. An explicit CLASS annotation still wins over provenance (the half of +// the old rule the narrowing preserves). +class Named5a { + k: number; + constructor(k: number) { + this.k = k; + } + twice(): number { + return this.k * 2; + } +} +function namedLocal(): string { + const o: Named5a = new Named5a(21); + return o.twice() + ":" + o.k; +} +console.log("p5a-named:" + namedLocal()); + +// 19. Subclass receiver on an INHERITED method: the clone is compiled for +// the declaring class, so an inherited call must NOT route to it. The +// subclass adds a field, so chain-global indexes differ from the base's. +class Base5a { + a: number; + constructor(a: number) { + this.a = a; + } + readA(): number { + return this.a * 10; + } +} +class Derived5a extends Base5a { + b: number; + constructor(a: number, b: number) { + super(a); + this.b = b; + } + sum(): number { + return this.a + this.b; + } +} +function inherited(): string { + const base = new Base5a(1); + const d = new Derived5a(2, 3); + // `d.readA()` resolves to Base5a::readA through the chain walk. + return base.readA() + ":" + d.readA() + ":" + d.sum() + ":" + d.a + ":" + d.b; +} +console.log("p5a-inherit:" + inherited()); From 49de992637e9ff4d163f3fd79f74cc42e202ad3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 28 Jul 2026 16:47:14 +0200 Subject: [PATCH 3/7] docs(changelog): key the Phase 5a fragment to PR #6925 --- ...M-repsel-p5a-proven-this.md => 6925-repsel-p5a-proven-this.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{PRNUM-repsel-p5a-proven-this.md => 6925-repsel-p5a-proven-this.md} (100%) diff --git a/changelog.d/PRNUM-repsel-p5a-proven-this.md b/changelog.d/6925-repsel-p5a-proven-this.md similarity index 100% rename from changelog.d/PRNUM-repsel-p5a-proven-this.md rename to changelog.d/6925-repsel-p5a-proven-this.md From e3dc9d296ae8174fecea18b1c845b5ef9683aa81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 28 Jul 2026 16:55:00 +0200 Subject: [PATCH 4/7] perf(codegen): carry the perry_static_ exclusion into the Phase 5a guarded routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defense in depth for the #1787 static-receiver bug: those targets need js_class_static_method_call, not a plain `call double`, and never have a proven-`this` clone. A static's registry key is distinct from an instance method's, so the two maps cannot currently disagree — but the guarded helper can receive a perry_static_ direct_fn, so the check belongs on both sites. --- .../src/lower_call/method_override.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/perry-codegen/src/lower_call/method_override.rs b/crates/perry-codegen/src/lower_call/method_override.rs index 0fb76106b1..7017e8eb67 100644 --- a/crates/perry-codegen/src/lower_call/method_override.rs +++ b/crates/perry-codegen/src/lower_call/method_override.rs @@ -774,10 +774,17 @@ pub(super) fn emit_guarded_direct_method_call( // needs no such guard because it never claims `JsNumber` — its // bare loads carry generic `JsValue` semantics (see // `collectors/proven_this.rs`). - let pshape_target = ctx - .pshape_methods - .contains_key(&(receiver_class_name.to_string(), property.to_string())) - .then(|| crate::collectors::pshape_method_name(direct_fn)); + // The `perry_static_` exclusion is carried forward from the + // guard-free site (the #1787 static-receiver bug): those targets + // need `js_class_static_method_call`, not a plain `call double`, + // and no proven-`this` clone is ever emitted for them. Belt and + // braces — a static's registry key is distinct from an instance + // method's, so the two maps cannot currently disagree. + let pshape_target = (!direct_fn.starts_with("perry_static_") + && ctx + .pshape_methods + .contains_key(&(receiver_class_name.to_string(), property.to_string()))) + .then(|| crate::collectors::pshape_method_name(direct_fn)); let target = pshape_target.as_deref().unwrap_or(direct_fn); ctx.block().call(DOUBLE, target, direct_arg_slices) } From c4ce9a72984115a85f4ae637c6238bd1fbf7d366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 28 Jul 2026 20:42:14 +0200 Subject: [PATCH 5/7] fix(codegen): stand down proven-`this` clones whose symbol collides with a user member (#6927) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pshape_method_name appends a suffix to the public symbol, so a class with methods `foo` and `foo__pshape` gave foo's clone the same LLVM symbol as foo__pshape's PUBLIC entry — two definitions of one name. Cross-class shapes collide identically (class `C__foo` + method `pshape`), so the check compares composed SYMBOLS from the registry, not member names. Pruned right after the method registry is built and before emit_module_artifacts reads cross_module.pshape_methods, so emission and call-site routing stay in lockstep — a call site can never route to a clone that was declined. Local mitigation for this phase's suffix only; the family-wide hazard across all generated clone suffixes is tracked in #6927. --- crates/perry-codegen/src/codegen/mod.rs | 9 ++ crates/perry-codegen/src/collectors/mod.rs | 2 +- .../src/collectors/proven_this.rs | 100 ++++++++++++++++++ .../test_gap_repsel_ptr_shape_locals.ts | 35 ++++++ 4 files changed, 145 insertions(+), 1 deletion(-) diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 003039a949..df68023ec1 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -1852,6 +1852,15 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> &module_prefix, ); + // Representation-selection Phase 5a: now that the method registry exists, + // drop any proven-`this` clone whose composed symbol would collide with a + // symbol a real user member already owns (issue #6927 tracks the + // family-wide fix for every generated-clone suffix). Pruning HERE — before + // `emit_module_artifacts` reads `cross_module.pshape_methods` for both + // emission and call-site routing — keeps the two in lockstep, so a call + // site can never route to a clone the emission loop declined to produce. + crate::collectors::prune_colliding_clones(&mut cross_module.pshape_methods, &method_names); + // Resolve user function names + signatures up front. See // `func_registry::build_func_registry`. let func_registry::FuncRegistry { diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index 6bb3dcb36b..ea5139af86 100644 --- a/crates/perry-codegen/src/collectors/mod.rs +++ b/crates/perry-codegen/src/collectors/mod.rs @@ -63,7 +63,7 @@ pub(crate) use integer_locals::{ pub(crate) use local_refs::{expr_contains_local_get, mark_all_candidate_refs_in_expr}; pub(crate) use mutation::has_any_mutation; pub(crate) use pointer_locals::collect_pointer_typed_locals; -pub(crate) use proven_this::{method_proven_this, pshape_method_name}; +pub(crate) use proven_this::{method_proven_this, prune_colliding_clones, pshape_method_name}; pub(crate) use ptr_numarray::{NumArrayDensity, NumArrayLocal}; pub(crate) use ptr_shape::PtrShapeLocal; pub(crate) use refs::{ diff --git a/crates/perry-codegen/src/collectors/proven_this.rs b/crates/perry-codegen/src/collectors/proven_this.rs index 54ed84ba66..6c1fdce7c2 100644 --- a/crates/perry-codegen/src/collectors/proven_this.rs +++ b/crates/perry-codegen/src/collectors/proven_this.rs @@ -89,6 +89,48 @@ pub(crate) fn pshape_method_name(public_name: &str) -> String { format!("{public_name}__pshape") } +/// Drop any proven-`this` clone whose composed symbol would collide with a +/// symbol the module already defines for a real, user-declared member. +/// +/// `pshape_method_name` builds the clone symbol by appending a suffix to the +/// public one, so a class with methods `foo` and `foo__pshape` would have +/// `foo`'s clone and `foo__pshape`'s PUBLIC entry resolve to the same LLVM +/// symbol — two definitions of one name. (Cross-class shapes collide the same +/// way, e.g. a class literally named `C__foo` with a method `pshape`, because +/// the `__` separators are not escaped either — which is why this compares +/// composed SYMBOLS from the registry rather than member names.) +/// +/// This is a **local mitigation for this phase's suffix only**. The hazard is +/// pre-existing and shared by the whole generated-clone family (`__generic`, +/// `__typed_f64`, `__typed_i32`, `__typed_i1`, `__typed_string`, +/// `__typed_f64_recv`, the Phase 2 specialized-ABI suffix, `__pshape`) — +/// `foo` + `foo__generic` +/// already collides today. The family-wide fix (a reserved escape in +/// `sanitize_member`, content-addressed mangling, or a uniquifier) is tracked +/// in **issue #6927**; it is deliberately not attempted here because it would +/// touch every clone kind plus Phase 2's specialized-ABI reachability ratchet +/// (whose allowlist must stay exactly as tight as it is — this comment +/// deliberately avoids naming that suffix literally, because the ratchet +/// scans for it). +/// +/// Standing down is always sound: without a clone, both routing sites fall +/// through to today's guarded lowering, which is correct for any receiver. +pub(crate) fn prune_colliding_clones( + pshape_methods: &mut HashMap<(String, String), PtrShapeLocal>, + method_names: &HashMap<(String, String), String>, +) { + if pshape_methods.is_empty() { + return; + } + let taken: HashSet<&str> = method_names.values().map(String::as_str).collect(); + pshape_methods.retain(|key, _| match method_names.get(key) { + // Keep only when the clone symbol is not already a defined symbol. + Some(public) => !taken.contains(pshape_method_name(public).as_str()), + // Not in the registry at all: it could never have been emitted. + None => false, + }); +} + /// The `Object.freeze` / `Object.seal` / `Object.preventExtensions` family. /// /// Deliberately NOT part of [`super::ptr_shape::expr_is_shape_barrier`] — see @@ -223,6 +265,64 @@ mod tests { assert!(!expr_is_freeze_barrier(&Expr::This)); } + /// Issue #6927: a class with methods `foo` and `foo__pshape` would give + /// `foo`'s clone the same LLVM symbol as `foo__pshape`'s PUBLIC entry. + /// The colliding clone must stand down (falling back to the always-correct + /// guarded lowering); the innocent one alongside it must survive. + #[test] + fn colliding_clone_symbols_are_pruned() { + let fact = || PtrShapeLocal { + class_name: "C".to_string(), + numeric_fields: HashSet::new(), + }; + let k = |m: &str| ("C".to_string(), m.to_string()); + let mut method_names = HashMap::new(); + method_names.insert(k("foo"), "perry_method_m__C__foo".to_string()); + // A real user member whose symbol IS `foo`'s clone symbol. + method_names.insert( + k("foo__pshape"), + "perry_method_m__C__foo__pshape".to_string(), + ); + method_names.insert(k("safe"), "perry_method_m__C__safe".to_string()); + + let mut pshape = HashMap::new(); + pshape.insert(k("foo"), fact()); + pshape.insert(k("safe"), fact()); + prune_colliding_clones(&mut pshape, &method_names); + + assert!( + !pshape.contains_key(&k("foo")), + "`foo`'s clone collides with `foo__pshape`'s public symbol and must be dropped" + ); + assert!( + pshape.contains_key(&k("safe")), + "a non-colliding clone must survive the prune" + ); + + // Cross-CLASS collision: class `C__foo` + method `pshape` composes the + // exact same symbol, because `__` separators are not escaped either. + let mut method_names = HashMap::new(); + method_names.insert(k("foo"), "perry_method_m__C__foo".to_string()); + method_names.insert( + ("C__foo".to_string(), "pshape".to_string()), + "perry_method_m__C__foo__pshape".to_string(), + ); + let mut pshape = HashMap::new(); + pshape.insert(k("foo"), fact()); + prune_colliding_clones(&mut pshape, &method_names); + assert!( + pshape.is_empty(), + "cross-class symbol collisions must be caught too — the check \ + compares composed SYMBOLS, not member names" + ); + + // A pair not present in the registry can never have been emitted. + let mut pshape = HashMap::new(); + pshape.insert(k("ghost"), fact()); + prune_colliding_clones(&mut pshape, &HashMap::new()); + assert!(pshape.is_empty()); + } + /// The proven-`this` clone suffix must never be registered into a runtime /// vtable or reachable from any indirect route. This is the Phase 5a twin /// of `spec_abi_symbol_reachability`; it is deliberately a SEPARATE diff --git a/test-files/test_gap_repsel_ptr_shape_locals.ts b/test-files/test_gap_repsel_ptr_shape_locals.ts index 00b42d286d..dff79552ea 100644 --- a/test-files/test_gap_repsel_ptr_shape_locals.ts +++ b/test-files/test_gap_repsel_ptr_shape_locals.ts @@ -433,3 +433,38 @@ function inherited(): string { return base.readA() + ":" + d.readA() + ":" + d.sum() + ":" + d.a + ":" + d.b; } console.log("p5a-inherit:" + inherited()); + +// 20. Clone-symbol collision (issue #6927). `tick`'s proven-`this` clone +// symbol is byte-identical to the PUBLIC symbol of a user method literally +// named `tick__pshape`. The colliding clone must stand down to the guarded +// lowering rather than emit a second definition of one LLVM symbol; the +// non-colliding sibling keeps its clone. Both must behave normally. +class Collide5a { + n: number; + constructor(n: number) { + this.n = n; + } + tick(): number { + this.n = this.n + 1; + return this.n; + } + // Deliberately named to collide with `tick`'s generated clone symbol. + tick__pshape(): number { + this.n = this.n + 100; + return this.n; + } + other(): number { + this.n = this.n + 1000; + return this.n; + } +} +function collide(): string { + const c = new Collide5a(1); + const a = c.tick(); + const b = c.tick__pshape(); + const d = c.other(); + let acc = 0; + for (let i = 0; i < 50; i++) acc += c.tick(); + return a + ":" + b + ":" + d + ":" + acc + ":" + c.n; +} +console.log("p5a-collide:" + collide()); From 948861d9ffee1b6db30c9c692b7f1ee61969e8f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 28 Jul 2026 20:52:03 +0200 Subject: [PATCH 6/7] docs(changelog): record the #6927 collision guard and the IR-verified GC contract --- changelog.d/6925-repsel-p5a-proven-this.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/changelog.d/6925-repsel-p5a-proven-this.md b/changelog.d/6925-repsel-p5a-proven-this.md index 93326d9065..d3a5f5f1d9 100644 --- a/changelog.d/6925-repsel-p5a-proven-this.md +++ b/changelog.d/6925-repsel-p5a-proven-this.md @@ -14,4 +14,8 @@ Two deliberate deviations from the plan, both documented inline: (1) the per-fie Also included, the adjacent `type_analysis/predicates.rs` fix: the `Named(_)` exclusion that made `const o: SomeIface = new C()` lose its Phase 3b proof is narrowed to "declared `Named(n)` where `n` is a known class". A TS `Named` annotation is just as often an interface or alias, which names no layout and is strictly weaker than the provenance proof; explicit class annotations still win, and `Generic { .. }` keeps its blanket exclusion (#6040). +**Clone-symbol collision guard (#6927).** `pshape_method_name` appends a suffix to the public symbol, so a class declaring both `foo` and `foo__pshape` would give `foo`'s clone the same LLVM symbol as `foo__pshape`'s PUBLIC entry — two definitions of one name. Cross-class shapes collide identically (a class literally named `C__foo` with a method `pshape`), because the `__` separators are not escaped either, so the check compares composed **symbols** from the method registry rather than member names. Colliding clones stand down to today's guarded lowering, which is correct for any receiver. Pruning happens right after the registry is built and before `emit_module_artifacts` reads `cross_module.pshape_methods`, so emission and call-site routing stay in lockstep and a call site can never route to a clone that was declined. This is a local mitigation for this phase's suffix only — the hazard is pre-existing and shared by the whole generated-clone family (`foo` + `foo__generic` already collides today); the family-wide fix is tracked in **#6927**. + +**GC contract under a moving collector, verified in IR (relevant to #6910).** `GC_TYPE_OBJECT` is movable, so the `TaPtr` no-shadow shortcut does not transfer. Two properties were checked rather than assumed: (1) the clone keeps `js_shadow_slot_bind` on the receiver — the shadow-frame setup in `compile_method` is unconditional, and this change touches only `llvm_name`, `linkage`, and public-trampoline re-emission; (2) no raw receiver pointer outlives a safepoint. Pre-`opt`, all 9 raw-pointer derivations in `Vec5a.normalizeTo`'s clone are fed by a fresh load of the shadow-bound slot occurring after the last preceding call — including across the internal `this.lenSq()` call. Post-`opt -O3`, LLVM inlines `lenSq` (removing that safepoint outright) and reloads the receiver from the slot after the remaining `js_dynamic_div` call; a dominance-aware scan finds all 7 surviving derivations in the same basic block as their defining load with no intervening call. The escaped shadow-bound alloca is what defeats CSE. The gap corpus additionally places allocation and call safepoints inside hot loops over proven receivers and passes byte-exact under `PERRY_GC_FORCE_EVACUATE=1` **and** `PERRY_GC_VERIFY_EVACUATION=1`. + Gated by `PERRY_PTR_SHAPE_THIS` (default on, `0`/`off`/`false` disables), keyed into the object cache. From 6e8b4d161a4e1f277ec0211034722fd046351e23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 29 Jul 2026 06:47:17 +0200 Subject: [PATCH 7/7] docs(changelog): don't present the FORCE_EVACUATE arm as evacuation-survival evidence #6950 measured PERRY_GC_FORCE_EVACUATE as inert on every reachable path (45 cycles, all manual full mark-sweeps, moved_objects=0). A green arm therefore shows byte-exact behavioural agreement with the flags set, not that a proven `this` receiver survives a live evacuation. The soundness argument for a moving collector is the static/IR one (retained js_shadow_slot_bind, no raw receiver pointer live across a safepoint). --- changelog.d/6925-repsel-p5a-proven-this.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/changelog.d/6925-repsel-p5a-proven-this.md b/changelog.d/6925-repsel-p5a-proven-this.md index d3a5f5f1d9..4149e8d4f1 100644 --- a/changelog.d/6925-repsel-p5a-proven-this.md +++ b/changelog.d/6925-repsel-p5a-proven-this.md @@ -16,6 +16,8 @@ Also included, the adjacent `type_analysis/predicates.rs` fix: the `Named(_)` ex **Clone-symbol collision guard (#6927).** `pshape_method_name` appends a suffix to the public symbol, so a class declaring both `foo` and `foo__pshape` would give `foo`'s clone the same LLVM symbol as `foo__pshape`'s PUBLIC entry — two definitions of one name. Cross-class shapes collide identically (a class literally named `C__foo` with a method `pshape`), because the `__` separators are not escaped either, so the check compares composed **symbols** from the method registry rather than member names. Colliding clones stand down to today's guarded lowering, which is correct for any receiver. Pruning happens right after the registry is built and before `emit_module_artifacts` reads `cross_module.pshape_methods`, so emission and call-site routing stay in lockstep and a call site can never route to a clone that was declined. This is a local mitigation for this phase's suffix only — the hazard is pre-existing and shared by the whole generated-clone family (`foo` + `foo__generic` already collides today); the family-wide fix is tracked in **#6927**. -**GC contract under a moving collector, verified in IR (relevant to #6910).** `GC_TYPE_OBJECT` is movable, so the `TaPtr` no-shadow shortcut does not transfer. Two properties were checked rather than assumed: (1) the clone keeps `js_shadow_slot_bind` on the receiver — the shadow-frame setup in `compile_method` is unconditional, and this change touches only `llvm_name`, `linkage`, and public-trampoline re-emission; (2) no raw receiver pointer outlives a safepoint. Pre-`opt`, all 9 raw-pointer derivations in `Vec5a.normalizeTo`'s clone are fed by a fresh load of the shadow-bound slot occurring after the last preceding call — including across the internal `this.lenSq()` call. Post-`opt -O3`, LLVM inlines `lenSq` (removing that safepoint outright) and reloads the receiver from the slot after the remaining `js_dynamic_div` call; a dominance-aware scan finds all 7 surviving derivations in the same basic block as their defining load with no intervening call. The escaped shadow-bound alloca is what defeats CSE. The gap corpus additionally places allocation and call safepoints inside hot loops over proven receivers and passes byte-exact under `PERRY_GC_FORCE_EVACUATE=1` **and** `PERRY_GC_VERIFY_EVACUATION=1`. +**GC contract under a moving collector, verified in IR (relevant to #6910).** `GC_TYPE_OBJECT` is movable, so the `TaPtr` no-shadow shortcut does not transfer. Two properties were checked rather than assumed: (1) the clone keeps `js_shadow_slot_bind` on the receiver — the shadow-frame setup in `compile_method` is unconditional, and this change touches only `llvm_name`, `linkage`, and public-trampoline re-emission; (2) no raw receiver pointer outlives a safepoint. Pre-`opt`, all 9 raw-pointer derivations in `Vec5a.normalizeTo`'s clone are fed by a fresh load of the shadow-bound slot occurring after the last preceding call — including across the internal `this.lenSq()` call. Post-`opt -O3`, LLVM inlines `lenSq` (removing that safepoint outright) and reloads the receiver from the slot after the remaining `js_dynamic_div` call; a dominance-aware scan finds all 7 surviving derivations in the same basic block as their defining load with no intervening call. The escaped shadow-bound alloca is what defeats CSE. + +The gap corpus additionally places allocation and call safepoints inside hot loops over proven receivers, and passes byte-exact with `PERRY_GC_FORCE_EVACUATE=1` and `PERRY_GC_VERIFY_EVACUATION=1` set. **That green arm is not evidence the proven-`this` receiver survives a live evacuation.** #6950 measured `FORCE_EVACUATE` to be inert on every reachable path (45 completed cycles, all manual full mark-sweeps, `moved_objects=0`), so what the arm establishes is byte-exact behavioural agreement *with the flags set* — nothing more. The actual soundness argument for a moving collector is the static/IR one above (retained `js_shadow_slot_bind`, no raw receiver pointer live across a safepoint), not the flag run. Gated by `PERRY_PTR_SHAPE_THIS` (default on, `0`/`off`/`false` disables), keyed into the object cache.