diff --git a/changelog.d/6925-repsel-p5a-proven-this.md b/changelog.d/6925-repsel-p5a-proven-this.md new file mode 100644 index 0000000000..4149e8d4f1 --- /dev/null +++ b/changelog.d/6925-repsel-p5a-proven-this.md @@ -0,0 +1,23 @@ +**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). + +**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 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. diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index 41fff64d1e..015fadd153 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` + // proven-`this` clone. Same HIR, same ABI, same shadow-bound + // tagged-at-rest receiver slot — only `this.field` lowering + // differs (bare fixed-offset access instead of the per-access + // guard diamond). Reached ONLY from the two call sites that + // already prove the receiver's exact shape; never registered into + // a runtime vtable. + if let Some(fact) = cross_module + .pshape_methods + .get(&(class.name.clone(), method.name.clone())) + { + compile_method( + llmod, + class, + method, + func_names, + strings, + class_table, + method_names, + module_globals, + module_global_types, + opts.import_function_prefixes, + enum_table, + static_field_globals, + class_ids, + func_signatures, + func_synthetic_arguments, + module_boxed_vars, + closure_rest_params, + cross_module, + None, + false, + Some(fact.clone()), + ) + .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..a2a63b3094 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..f46cc86e79 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 { + 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 { 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..df68023ec1 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -1340,6 +1340,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 +1361,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 +1624,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 +1670,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(), @@ -1826,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/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index d3b5c81d59..7528b1cf9c 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -797,6 +797,15 @@ 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` 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>, /// 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/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index 5908e7c569..ea5139af86 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; @@ -62,6 +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, 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 new file mode 100644 index 0000000000..6c1fdce7c2 --- /dev/null +++ b/crates/perry-codegen/src/collectors/proven_this.rs @@ -0,0 +1,383 @@ +//! 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_classes, chain_field_names, chain_method_map, 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 `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") +} + +/// 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 +/// `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. 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; + } + + 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 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; + } + if let Expr::PropertyGet { + object, property, .. + } = e + { + if matches!(object.as_ref(), Expr::This) && 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)); + } + + /// 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 + /// 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..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. -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 +422,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 +456,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 +466,9 @@ 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 +896,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, @@ -907,9 +913,50 @@ 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> { + /// 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(), + allow_this_in_store_values: true, + } + } + + /// 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 +984,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) } @@ -1072,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 { @@ -1107,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 517f558a15..191ca2c860 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); } @@ -583,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/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..15f638138a 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 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. + /// + /// `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 proven-`this` clone. + /// The two proven call sites consult this before routing; a hit also + /// proves the receiver's exact class DECLARES the method (own + /// declarations only), which is what rules out a subclass `this`. + pub pshape_methods: + &'a std::collections::HashMap<(String, String), crate::collectors::PtrShapeLocal>, + /// 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 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, + /// 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..c44df691a4 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 + // 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) + .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..bbc8722b6e 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(); @@ -484,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/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..7017e8eb67 100644 --- a/crates/perry-codegen/src/lower_call/method_override.rs +++ b/crates/perry-codegen/src/lower_call/method_override.rs @@ -751,7 +751,42 @@ 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`). + // 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) } }; 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..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 @@ -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::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)); } 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). 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..dff79552ea 100644 --- a/test-files/test_gap_repsel_ptr_shape_locals.ts +++ b/test-files/test_gap_repsel_ptr_shape_locals.ts @@ -214,3 +214,257 @@ 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()); + +// 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());