diff --git a/benchmarks/repsel_census/baseline.json b/benchmarks/repsel_census/baseline.json index 6258e4f95b..8ebe2b76c1 100644 --- a/benchmarks/repsel_census/baseline.json +++ b/benchmarks/repsel_census/baseline.json @@ -116,7 +116,7 @@ "role": "corpus", "source": "benchmarks/app-patterns/kernels/batch.ts", "floors": { - "ptr-shape": 0, + "ptr-shape": 2, "ptr-numarray": 0, "canonical-i32": 3, "canonical-u32": 0, @@ -126,7 +126,7 @@ "spec-abi-taptr-slot": 0 }, "candidates": { - "ptr-shape": 4, + "ptr-shape": 5, "ptr-numarray": 0, "canonical-slot": 3, "int-valued-ta": 0, @@ -508,5 +508,5 @@ } } ], - "generated_at": "2026-07-31T01:39:37.595749Z" + "generated_at": "2026-07-31T02:23:42.048018Z" } diff --git a/changelog.d/7107-repsel-return-shape-facts.md b/changelog.d/7107-repsel-return-shape-facts.md new file mode 100644 index 0000000000..f143336284 --- /dev/null +++ b/changelog.d/7107-repsel-return-shape-facts.md @@ -0,0 +1,24 @@ +**Representation selection — `Ptr` survives the return escape (#7034 §4, phase P2).** + +`Ptr` promoted **zero** locals on `benchmarks/app-patterns/kernels/batch.ts`, the object/property-heavy workload the representation exists for. `collectors/ptr_shape.rs` rule 2 (containment) listed `return` as an outright disqualifier, and every real record escapes its producing scope, so the proof died at the first escape. `PERRY_PTR_SHAPE_LOCALS=0` versus default produced an identical `__text` — there was nothing to switch off. + +Two halves, both gated by the existing `PERRY_PTR_SHAPE_LOCALS` (no new env knob, so no new unexercised off-state): + +- **Producer side** — `return ` no longer disqualifies. A `return` is a terminator: every use of the local in that body either precedes it on that path or is unreachable from it, the sole exception being a `finally` block, which still runs before the caller resumes and whose uses the same walk checks anyway. The caller cannot have reshaped the object at any access this pass licenses. Only the **bare** form is exempt — `return [o]`, `return {a: o}`, `return f(o)` still escape — and a `return` inside a nested closure body is **not** exempt, because that value escapes at an unbounded later time (`UseWalk::in_closure`). +- **Caller side** (new `collectors/ptr_shape_returns.rs`) — a module function whose every return path hands back a *freshly allocated, unaliased* object of one class carries a **return-shape fact**, and a direct call to it is rule-1 provenance of exactly `new C(...)` strength. Freshness is discharged by re-running the full Phase 3b proof over the producer's body rather than by a second, weaker approximation of it: a local that proof promotes has, by rule 2, no alias anywhere. `return CACHE`, a fall-through-to-`undefined` path, a bare `return;`, disagreeing return classes, an async/generator producer, and an indirect callee all yield no fact. + +No ABI change, no function cloning, no cross-call-site agreement — that is why returns went first among the three escape positions (#7034 §5). + +**Measured.** `batch.ts`: **0 → 2** promoted locals (`acc` in `totalsRow`, producer side; `totals` at module scope, caller side). `benchmarks/app-patterns/kernels/`: 2 → 4. `benchmarks/suite/`: unchanged at 4 — the micro-benchmarks do not use the record-producing idiom. The promotion census (#7104) reports the same delta independently and its `batch` `ptr-shape` floor is ratcheted 0 → 2 here, so the gain is now gated: with `PERRY_PTR_SHAPE_LOCALS=0` the census goes red on `batch 0 (floor 2)`. + +The A/B that motivated the work stops being vacuous: `PERRY_PTR_SHAPE_LOCALS=0` vs default now moves `__text` by **1,532 bytes** on `batch.ts` (9,674,204 → 9,672,672), where before it moved nothing; in the emitted IR, guard-gate volatile loads drop 26 → 22 and by-name field fallbacks 53 → 51. That is a **size** number: no speed claim is made, because the box was under load 40–135 throughout and nothing was timed. + +**Known limitation, stated rather than papered over:** module-init contexts set `repsel_context_allows_canonical_i32: false` (a pre-existing Phase 1 decision in `codegen/entry.rs`), and `FnCtx::ptr_shape_receiver_fact` gates on that flag. So `totals` — a module-scope binding — is proven and reported as a win but its access sites keep the guarded lowering. One of the two `batch.ts` promotions is therefore currently unconsumed; the 1,532-byte `__text` delta is attributable to `acc` alone. Making module-init consume `Ptr` is a separate, independently-measurable change. + +**GC contract.** No new site holds an object pointer. The caller's binding is an ordinary NaN-boxed local slot, shadow-bound by `collect_pointer_typed_locals` / `js_shadow_slot_bind` exactly as before; verified in the emitted IR that the returned register is stored to that slot and bound with no intervening allocation or call, and that every access re-derives the raw pointer from the slot inside one region. `TaPtr`'s callee-side no-bind shortcut is explicitly not copied — it is sound only for non-movable typed-array storage, and `GC_TYPE_OBJECT` moves (#6990, #7019). One new guard closes a hazard the original sketch did not have: a producer annotated with a definitely-non-pointer return type would cost the caller's binding its shadow slot (`collect_pointer_typed_locals` drops it), leaving a promoted `Ptr` local in an unrooted alloca; since Perry does not check annotations, such a producer carries no fact. + +A call-seeded candidate never claims `numeric_fields`: the producer's stores are outside the caller's region, so no exhaustive-reachable-store proof is available. Same stand-down, same reason, as `collectors/proven_this.rs`. + +**Verification.** 16 new unit tests in `ptr_shape_returns_tests.rs`, each guard sabotage-verified — removing it makes exactly the test that names it fail. New corpus member `test-files/test_gap_repsel_return_shape.ts`, registered in `test-parity/gc_repsel_corpus.txt`, byte-exact against the pinned Node 26.5.1 oracle on the default, `PERRY_PTR_SHAPE_LOCALS=0`, `PERRY_GC_HEAP_LIMIT=8`, `PERRY_GC_FORCE_EVACUATE=1` and conservative-scan-off arms. Its `survivesGc` case is **GC-live by measurement, not by hope**: a first draft using non-escaping churn drove zero collections (the #6942/#6946 inert-arm failure mode, caught before shipping); the committed version drives 6–8 copying minors, ~1M objects copied, and 12–13 shadow-stack slots rewritten by the collector while the output stays oracle-exact. A base-vs-new behavioural A/B over the gap corpus showed no output change attributable to this work. + +Review follow-up: `is_definitely_non_pointer_type` is hoisted to module scope in `collectors/pointer_locals.rs` and called from here rather than restated — a copy drifting by one `Type` variant would mean a value the slot-assigning pass leaves unrooted while this one treats it as a live, movable pointer. diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index 04373a21cb..e07d6d80f4 100644 --- a/crates/perry-codegen/src/collectors/mod.rs +++ b/crates/perry-codegen/src/collectors/mod.rs @@ -27,6 +27,7 @@ mod proven_this; mod ptr_numarray; mod ptr_shape; mod ptr_shape_report; +mod ptr_shape_returns; mod refs; mod scalar_method_dispatch; mod scalar_methods; diff --git a/crates/perry-codegen/src/collectors/pointer_locals.rs b/crates/perry-codegen/src/collectors/pointer_locals.rs index c2240bb0c8..e51a5bbe09 100644 --- a/crates/perry-codegen/src/collectors/pointer_locals.rs +++ b/crates/perry-codegen/src/collectors/pointer_locals.rs @@ -181,6 +181,31 @@ impl HirTypeFacts for PointerAnalysisFacts<'_> { } } +/// Types that can NEVER hold a heap pointer, and therefore cost a local its +/// shadow-stack slot in [`collect_pointer_typed_locals`]. +/// +/// **This is the single definition.** It used to be a nested `fn` inside +/// `collect_pointer_typed_locals`; it is module-level and `pub(crate)` because +/// anything that decides a value may be treated as a rooted pointer has to +/// agree with the pass that actually assigns the root slot. A second copy +/// drifting by one `Type` variant would mean a value this collector left +/// unrooted while another pass treated it as a live, movable pointer — a +/// use-after-move under the evacuating minor (#7019), not a cosmetic +/// inconsistency. `collectors/ptr_shape_returns.rs` (#7034 §4) is the current +/// second caller. +pub(crate) fn is_definitely_non_pointer_type(ty: &Type) -> bool { + matches!( + ty, + Type::Number + | Type::Int32 + | Type::Boolean + | Type::Null + | Type::Void + | Type::Never + | Type::Symbol + ) || matches!(ty, Type::Union(variants) if variants.iter().all(is_definitely_non_pointer_type)) +} + pub fn collect_pointer_typed_locals( params: &[perry_hir::Param], stmts: &[perry_hir::Stmt], @@ -222,19 +247,6 @@ pub fn collect_pointer_typed_locals( ) || matches!(ty, Type::Union(variants) if variants.iter().any(is_ptr_typed)) } - fn is_definitely_non_pointer_type(ty: &Type) -> bool { - matches!( - ty, - Type::Number - | Type::Int32 - | Type::Boolean - | Type::Null - | Type::Void - | Type::Never - | Type::Symbol - ) || matches!(ty, Type::Union(variants) if variants.iter().all(is_definitely_non_pointer_type)) - } - fn expr_value_type( expr: &Expr, local_types: &HashMap, diff --git a/crates/perry-codegen/src/collectors/ptr_shape.rs b/crates/perry-codegen/src/collectors/ptr_shape.rs index 346985e32b..7383bf0a0b 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape.rs @@ -26,14 +26,28 @@ //! constructors cannot return an override object). Anon-shape literals //! (`{k: v}` closed shapes) and `{}` builder sites also lower to //! `Expr::New { class_name: "__AnonShape_…" }`, so records qualify through -//! the same test. +//! the same test. Since #7034 §4 a direct call to a module function +//! carrying a **return-shape fact** is provenance of the same strength — +//! that fact certifies the callee hands back a freshly allocated, unaliased +//! `C` on every return path (`collectors/ptr_shape_returns.rs`). //! 2. **Containment**: every use of the local is a declared-chain field //! read/write/update or a vetted method call. Any other use — reassignment, -//! closure capture, call argument, array/object element, return, throw, +//! closure capture, call argument, array/object element, throw, //! `delete`, freeze/seal, aliasing — disqualifies. The object is therefore //! unreachable from anywhere except this local, so no §5.2 barrier //! (defineProperty / delete / setPrototypeOf / Proxy / mutating Reflect) //! can reach it *through an alias*. +//! +//! **Exception — the return position (#7034 §4).** `return ` +//! does NOT disqualify. Containment exists to bound the object's aliases +//! *while this function still reads it*, and a `return` is a terminator: +//! every use of the local in this body either precedes it on that path or +//! is unreachable from it, the sole exception being a `finally` block — +//! which still runs before the caller resumes, and whose uses this same +//! walk checks anyway. The caller cannot have touched the object yet, so +//! no shape transition can have happened at any access this pass licenses. +//! Returns nested inside a CLOSURE body are not exempt: that value escapes +//! at an unbounded later time (`UseWalk::in_closure`). //! 3. **`this`-flow containment**: the constructor chain, chain field //! initializers, and every method called on the local are walked with a //! strict `this`-usage discipline (field access on `this`, vetted @@ -169,6 +183,11 @@ fn note_ptr_shape_local( names: &HashMap, depths: &HashMap, ) { + // #7034 §4: `report::suppressed()` is set while the return-shape module + // pre-pass re-runs this proof speculatively — see `SuppressScope`. + if report::suppressed() { + return; + } if opt_report::enabled() { let fallback = format!(""); let name = names.get(&id).map(String::as_str).unwrap_or(&fallback); @@ -265,6 +284,15 @@ pub(crate) fn collect_shape_proven_ptr_locals( // async-to-generator transform). let mut candidates: HashMap = HashMap::new(); super::find_new_candidates(stmts, boxed_vars, module_globals, &mut candidates); + // #7034 §4: `const r = producer(...)` where `producer` carries a + // return-shape fact is provenance of `new`-strength (module doc, rule 1). + let return_seeded = super::ptr_shape_returns::find_return_shape_candidates( + stmts, + boxed_vars, + module_globals, + module_dispatch, + &mut candidates, + ); if candidates.is_empty() { return HashMap::new(); } @@ -326,6 +354,8 @@ pub(crate) fn collect_shape_proven_ptr_locals( const_local_inits: HashMap::new(), disq_reasons: HashMap::new(), escape_ctx: report::ESC_BARE_REFERENCE, + return_seeded: &return_seeded, + in_closure: false, }; walk.walk_stmts(stmts); let UseWalk { @@ -421,18 +451,31 @@ pub(crate) fn collect_shape_proven_ptr_locals( .filter(|(_, r)| *r == id) .map(|(m, _)| *m) .collect(); - let numeric_fields = prove_numeric_fields( - &chain, - &members, - &store_records, - field_stores.get(id).map(Vec::as_slice).unwrap_or(&[]), - new_args.get(id).copied().unwrap_or(&[]), - called, - &super_call_args, - &internally_invoked, - not_bigint_locals, - &const_local_inits, - ); + // #7034 §4: a return-shape-seeded candidate NEVER claims numeric + // fields. The numeric proof is an EXHAUSTIVE-reachable-store proof, + // and the producer's own stores (`acc.weight = …` inside the callee) + // are not in this region at all — claiming `JsNumber` off the + // constructor's stores alone would let a guard-free `load double` in + // a number context read a slot the producer had put a string in. The + // shape proof by itself still retires the whole guard diamond; this + // is the same stand-down `collectors/proven_this.rs` makes, for the + // same reason. + let numeric_fields = if return_seeded.contains(id) { + HashSet::new() + } else { + prove_numeric_fields( + &chain, + &members, + &store_records, + field_stores.get(id).map(Vec::as_slice).unwrap_or(&[]), + new_args.get(id).copied().unwrap_or(&[]), + called, + &super_call_args, + &internally_invoked, + not_bigint_locals, + &const_local_inits, + ) + }; let fact = PtrShapeLocal { class_name: class_name.clone(), numeric_fields, @@ -610,6 +653,15 @@ struct UseWalk<'a> { /// Parent arms narrow it (`return`, call argument, array element, …) so /// the report can say *how* the object escaped, not just that it did. escape_ctx: ShapeDenial, + /// #7034 §4: candidates whose provenance is a return-shape-carrying CALL + /// rather than a `new`. Their `Let` init is an `Expr::Call`, which rule 1 + /// would otherwise reject as `LET_INIT_NOT_NEW`. + return_seeded: &'a HashSet, + /// #7034 §4: are we inside a closure body? A `return ` there + /// escapes at an unbounded later time, so the return exemption (module + /// doc, rule 2) does NOT apply — only the enclosing function's own + /// returns are terminators for this local's lifetime. + in_closure: bool, } impl<'a> UseWalk<'a> { @@ -676,6 +728,20 @@ impl<'a> UseWalk<'a> { } return; } + // #7034 §4: a return-shape-seeded candidate's provenance + // is the CALL. It records no `new_args` — the constructor + // ran in the callee, so the numeric-field proof stands + // down for these candidates entirely (see the `'cand` + // loop). The argument expressions are ordinary values; + // walk them so OTHER candidates passed there still escape. + if self.return_seeded.contains(id) { + if let Some(Expr::Call { args, .. }) = init.as_ref() { + for a in args { + self.with_ctx(report::ESC_CALL_ARGUMENT, |w| w.walk_expr(a)); + } + return; + } + } // A candidate whose Let init is not the New (var-redecl // seed) is not provenance-stable. self.disq(*id, report::LET_INIT_NOT_NEW); @@ -724,6 +790,19 @@ impl<'a> UseWalk<'a> { Stmt::Throw(e) => self.with_ctx(report::ESC_THROWN, |w| w.walk_expr(e)), Stmt::Return(opt) => { if let Some(e) = opt { + // #7034 §4: `return ` is exempt — see the + // module doc, rule 2. Only the bare form: `return {a: o}` + // or `return f(o)` embeds the object in a value whose + // other references this walk has not bounded, and a + // return inside a closure body is not a terminator for + // the enclosing function's local. + if !self.in_closure { + if let Expr::LocalGet(id) = e { + if self.tracked_root(*id).is_some() { + return; + } + } + } self.with_ctx(report::ESC_RETURN, |w| w.walk_expr(e)); } } @@ -1012,7 +1091,10 @@ impl<'a> UseWalk<'a> { for c in captures.iter().chain(mutable_captures.iter()) { self.disq(*c, report::ESC_CLOSURE_CAPTURE); } + let outer = self.in_closure; + self.in_closure = true; self.walk_stmts(body); + self.in_closure = outer; } // Everything else: recurse into children; a bare LocalGet of a // candidate in any unhandled position hits the LocalGet arm above diff --git a/crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs b/crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs index 6fbb1488fd..fcaef635c7 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs @@ -108,6 +108,12 @@ fn run(stmts: &[Stmt], classes: &HashMap) -> HashMap = const { std::cell::Cell::new(false) }; +} + +/// True while a [`SuppressScope`] is alive on this thread. +pub(super) fn suppressed() -> bool { + SUPPRESS.with(|s| s.get()) +} + +/// RAII: silence `--opt-report` / `PERRY_REPSEL_DEBUG` recording for the +/// duration of a speculative proof query. Restores the previous state, so +/// nesting is safe. +pub(super) struct SuppressScope(bool); + +impl SuppressScope { + pub(super) fn new() -> Self { + SuppressScope(SUPPRESS.with(|s| s.replace(true))) + } +} + +impl Drop for SuppressScope { + fn drop(&mut self) { + SUPPRESS.with(|s| s.set(self.0)); + } +} + /// Record one denied `Ptr` candidate. pub(super) fn deny_local( id: u32, @@ -293,7 +325,7 @@ pub(super) fn deny_local( ) { // Guard here, not just inside `opt_report::deny`: the arguments below // allocate, and they are evaluated before the callee can early-return. - if !opt_report::enabled() { + if !opt_report::enabled() || suppressed() { return; } let fallback = format!(""); @@ -315,7 +347,7 @@ pub(super) fn deny_local( /// Record an allocation site that never became a candidate (rule 1). pub(super) fn deny_alloc_site(site: &NewSite) { - if !opt_report::enabled() { + if !opt_report::enabled() || suppressed() { return; } opt_report::deny(Denial { diff --git a/crates/perry-codegen/src/collectors/ptr_shape_returns.rs b/crates/perry-codegen/src/collectors/ptr_shape_returns.rs new file mode 100644 index 0000000000..363aa5a7da --- /dev/null +++ b/crates/perry-codegen/src/collectors/ptr_shape_returns.rs @@ -0,0 +1,352 @@ +//! Representation-selection Phase 3b, **#7034 §4: return-shape facts**. +//! +//! ## What this proves, and why it is the cheapest of the three escapes +//! +//! `#7034` measured `Ptr` promotion on `benchmarks/app-patterns/ +//! kernels/batch.ts` — the object/property-heavy workload the representation +//! exists for — and found **zero** promoted locals. Every real record escapes +//! its producing scope, and `collectors/ptr_shape.rs` rule 2 listed `return`, +//! `call argument` and `array/object element` as outright disqualifiers, so +//! the proof died at the first escape. +//! +//! Returns are the escape that needs no cross-function agreement, no ABI +//! change, and no clone-and-route machinery at all (contrast `proven_this.rs`, +//! whose `this` proof emits an internal per-method clone and routes call sites +//! to it). Two independent halves: +//! +//! 1. **Producer side** (`ptr_shape.rs`, rule 2's return exemption). A local +//! whose every other use is contained is promoted even though it is +//! returned, because a `return` is a terminator: the caller cannot observe +//! — let alone reshape — the object until after every access this pass +//! licenses has already run. +//! 2. **Caller side** (this file). If a module function's every return path +//! hands back a *freshly allocated, unaliased* object of one class, a call +//! to it is rule-1 provenance of exactly `new C(...)` strength, so +//! `const r = producer(...)` becomes an ordinary `Ptr` candidate +//! and every `r.field` in the caller lowers guard-free. +//! +//! ## The freshness obligation, and how it is discharged +//! +//! Rule 1's `new C(...)` seed carries two facts: the dynamic class is exactly +//! `C`, and — because the allocation is *right there* — no other reference to +//! the object exists yet. A call site sees neither for free: `function get() { +//! return CACHE; }` also "returns a `C`", but its result is aliased by the +//! module, so a caller-side shape proof over it would be unsound the moment +//! anything else reshapes `CACHE`. +//! +//! So a return-shape fact is only issued when **every** return of the producer +//! is one of: +//! +//! * `return new C(...)` — fresh by construction. (Object literals lower to +//! `Expr::New { class_name: "__AnonShape_…" }`, so `return { k: v }` is this +//! case too.) The constructor's `this`-flow safety is re-proven by the +//! *caller's* own rule 3 walk over `C`, which runs on the seeded candidate +//! exactly as it does for a literal `new`. +//! * `return ` where that local is itself a **fully proven Phase 3b +//! local of the producer's body**. That is the whole containment proof — +//! single `Let` with a `new C(...)` init, every use a declared-chain field +//! access or a `this`-flow-vetted method call, no alias, no capture, no +//! other escape — which is precisely "no reference to this object exists +//! anywhere but the returned one". It is discharged by re-running +//! `collect_shape_proven_ptr_locals` over the producer's body rather than +//! by a second, weaker approximation of it. +//! +//! Anything else — `return CACHE`, `return this.field`, `return mk()`, +//! `return cond ? a : b` — yields no fact. +//! +//! ## Why the producer must not fall off its end +//! +//! `function f(x) { if (x) return new C(); }` returns `undefined` on the other +//! path. A caller that treated the result as a proven `C` would emit a bare +//! fixed-offset load against a NaN-boxed `undefined`. The fact therefore also +//! requires that control cannot reach the end of the body: the last statement +//! must itself be a value-returning `return`, or a `throw`. +//! +//! ## GC contract +//! +//! **Nothing here introduces a new site that holds an object pointer.** The +//! caller-side binding is an ordinary NaN-boxed local slot — the same storage +//! it had before this pass existed — shadow-bound by +//! `collect_pointer_typed_locals` / `js_shadow_slot_bind` like any other +//! object local, and the returned register is dead the moment the `Let` stores +//! it. Every access re-derives the raw pointer from that slot inside one +//! region (`ptr_shape.rs`'s tagged-at-rest contract), so an evacuating +//! scavenge that moves the object rewrites the bound slot and the next access +//! observes the new address. `TaPtr`'s callee-side no-bind shortcut is NOT +//! copied here — it is sound only for non-movable typed-array storage, and +//! `GC_TYPE_OBJECT` is movable (#6990, #7019). +//! +//! ## Numeric fields are deliberately not claimed +//! +//! See the `'cand` loop in `ptr_shape.rs`: the producer's own stores are not +//! in the caller's region, so no exhaustive-reachable-store proof is +//! available. Same stand-down, same reason, as `proven_this.rs`. +//! +//! Gated by `PERRY_PTR_SHAPE_LOCALS` along with the rest of Phase 3b — no new +//! env knob, so there is no new unexercised off-state. + +use std::collections::{HashMap, HashSet}; + +use perry_hir::{Class, Expr, Function, Module, Stmt}; + +use super::ptr_shape::{chain_admissible, ptr_shape_locals_enabled}; +use super::ptr_shape_report as report; +use super::ModuleDispatchFacts; + +/// Module pre-pass: which module-level functions carry a return-shape fact. +/// +/// `facts` must already have its barrier flags final and its own +/// `return_shape_functions` map still EMPTY — the per-producer proof re-enters +/// [`super::ptr_shape::collect_shape_proven_ptr_locals`], which consults that +/// map, and an empty map is what makes the recursion impossible. +pub(crate) fn collect_return_shape_functions( + facts: &ModuleDispatchFacts, + hir: &Module, +) -> HashMap { + let mut out = HashMap::new(); + if !ptr_shape_locals_enabled() || facts.has_shape_barrier_sites() { + return out; + } + let classes: HashMap = hir + .classes + .iter() + .map(|c| (c.name.clone(), c)) + .collect::>(); + for f in &hir.functions { + if let Some(class_name) = producer_return_class(f, &classes, facts) { + out.insert(f.id, class_name); + } + } + out +} + +/// The class a call to `f` provably returns, or `None`. +fn producer_return_class( + f: &Function, + classes: &HashMap, + facts: &ModuleDispatchFacts, +) -> Option { + // Context restrictions, identical to every other Phase 1/3a/3b analysis: + // the async-to-generator transform boxes body locals into one shared + // mutable cell, so no containment fact survives it. A generator's `return` + // is also not a single-exit terminator in the sense rule 2 relies on. + if f.is_async || f.is_generator || f.was_plain_async { + return None; + } + // GC: the caller's binding must be able to GET a shadow slot. + // `collect_pointer_typed_locals` drops the slot for a local it can prove + // non-pointer, and it proves that from the INIT expression's type — which + // for a call is the callee's declared return type. A producer annotated + // `: number` that actually returns an object would leave the caller with + // a promoted `Ptr` local in an UNROOTED alloca; an evacuating minor + // would then move the object without rewriting the slot (#7019 ships that + // default-on). Perry does not check annotations, so refuse the fact rather + // than trust one. `Any`/`Unknown`/named/object types all keep the slot. + // + // Calls `pointer_locals`'s own predicate rather than restating it: a second + // copy drifting by one `Type` variant is precisely how a value ends up + // unrooted there while this pass treats it as a live movable pointer. + if super::pointer_locals::is_definitely_non_pointer_type(&f.return_type) { + return None; + } + // The body must not be able to fall off its end (module doc). + match f.body.last() { + Some(Stmt::Return(Some(_))) | Some(Stmt::Throw(_)) => {} + _ => return None, + } + let mut returns = Vec::new(); + if !collect_own_returns(&f.body, &mut returns) { + // A bare `return;` — the caller would see `undefined`. + return None; + } + if returns.is_empty() { + return None; + } + + // Every return must agree on one class, and each must be a fresh form. + let mut class_name: Option<&str> = None; + let mut needs_body_proof: Vec = Vec::new(); + for r in &returns { + let (name, local) = match r { + Expr::New { class_name: c, .. } => (c.as_str(), None), + Expr::LocalGet(id) => { + // Resolved against the producer's own Phase 3b proof below; + // find its declared class first so disagreement short-circuits. + let c = seeded_class_of_local(&f.body, *id)?; + (c, Some(*id)) + } + _ => return None, + }; + match class_name { + None => class_name = Some(name), + Some(prev) if prev == name => {} + Some(_) => return None, + } + if let Some(id) = local { + needs_body_proof.push(id); + } + } + let class_name = class_name?; + // Cheap rejections before the (relatively expensive) body proof: the class + // must be one this module declares and Phase 3b admits at all. + if !classes.contains_key(class_name) || !chain_admissible(classes, class_name) { + return None; + } + + if !needs_body_proof.is_empty() { + // Discharge freshness by re-running the FULL Phase 3b proof over the + // producer's body: a local it promotes has, by rule 2, no alias + // anywhere, so the returned reference is the only one in existence. + // + // `module_globals` is empty on purpose and is not an approximation: + // `codegen/module_globals_emit.rs` only ever records ids of top-level + // `hir.init` lets, and every candidate here comes from a `Stmt::Let` + // inside this function body. + let boxed = crate::boxed_vars::collect_boxed_vars(&f.body); + let _quiet = report::SuppressScope::new(); + let promoted = super::ptr_shape::collect_shape_proven_ptr_locals( + &f.body, + &boxed, + &HashMap::new(), + classes, + facts, + &HashSet::new(), + ); + for id in &needs_body_proof { + match promoted.get(id) { + Some(fact) if fact.class_name == class_name => {} + _ => return None, + } + } + } + Some(class_name.to_string()) +} + +/// The class of the `new` that a `Stmt::Let` in `stmts` binds to `want`. +/// `None` when the id is not bound by exactly one `Let { init: New }` here. +fn seeded_class_of_local(stmts: &[Stmt], want: u32) -> Option<&str> { + let mut found: Option<&str> = None; + let mut count = 0usize; + walk_stmts(stmts, &mut |s| { + if let Stmt::Let { id, init, .. } = s { + if *id == want { + count += 1; + if let Some(Expr::New { class_name, .. }) = init.as_ref() { + found = Some(class_name.as_str()); + } + } + } + }); + if count == 1 { + found + } else { + None + } +} + +/// Collect the value expressions of every `return` **of this function** — +/// deliberately NOT descending into nested closure bodies, whose returns +/// belong to the closure. Returns `false` if a bare `return;` was found. +fn collect_own_returns<'a>(stmts: &'a [Stmt], out: &mut Vec<&'a Expr>) -> bool { + let mut ok = true; + walk_stmts(stmts, &mut |s| { + if let Stmt::Return(r) = s { + match r { + Some(e) => out.push(e), + None => ok = false, + } + } + }); + ok +} + +/// Statement walker over one function body, skipping nested closure bodies +/// (they are separate regions with their own returns and their own proof). +fn walk_stmts<'a>(stmts: &'a [Stmt], f: &mut impl FnMut(&'a Stmt)) { + for s in stmts { + f(s); + match s { + Stmt::If { + then_branch, + else_branch, + .. + } => { + walk_stmts(then_branch, f); + if let Some(eb) = else_branch { + walk_stmts(eb, f); + } + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => walk_stmts(body, f), + Stmt::For { init, body, .. } => { + if let Some(init) = init { + walk_stmts(std::slice::from_ref(init.as_ref()), f); + } + walk_stmts(body, f); + } + Stmt::Try { + body, + catch, + finally, + } => { + walk_stmts(body, f); + if let Some(c) = catch { + walk_stmts(&c.body, f); + } + if let Some(fin) = finally { + walk_stmts(fin, f); + } + } + Stmt::Switch { cases, .. } => { + for c in cases { + walk_stmts(&c.body, f); + } + } + Stmt::Labeled { body, .. } => walk_stmts(std::slice::from_ref(body.as_ref()), f), + _ => {} + } + } +} + +/// Caller-side seeding: add every `const r = (...)` in +/// this region to `candidates`, and return the set of ids so seeded. +/// +/// Mirrors `find_new_candidates`' shape exactly — same exclusions (boxed, +/// module-global), same nesting, and no descent into closure bodies (each is +/// its own region). +pub(crate) fn find_return_shape_candidates( + stmts: &[Stmt], + boxed_vars: &HashSet, + module_globals: &HashMap, + module_dispatch: &ModuleDispatchFacts, + candidates: &mut HashMap, +) -> HashSet { + let mut seeded = HashSet::new(); + walk_stmts(stmts, &mut |s| { + let Stmt::Let { + id, + init: Some(Expr::Call { callee, .. }), + .. + } = s + else { + return; + }; + if boxed_vars.contains(id) || module_globals.contains_key(id) { + return; + } + // Only a direct `Expr::FuncRef` callee names one statically-known + // function — the same resolution `clamp3_functions` / hot-callee + // inlining already rely on. Anything computed could be rebound. + let Expr::FuncRef(func_id) = callee.as_ref() else { + return; + }; + if let Some(class_name) = module_dispatch.return_shape_class(*func_id) { + candidates.insert(*id, class_name.to_string()); + seeded.insert(*id); + } + }); + seeded +} + +#[cfg(test)] +#[path = "ptr_shape_returns_tests.rs"] +mod tests; diff --git a/crates/perry-codegen/src/collectors/ptr_shape_returns_tests.rs b/crates/perry-codegen/src/collectors/ptr_shape_returns_tests.rs new file mode 100644 index 0000000000..02b0d889f2 --- /dev/null +++ b/crates/perry-codegen/src/collectors/ptr_shape_returns_tests.rs @@ -0,0 +1,583 @@ +//! #7034 §4 return-shape facts: both halves of the proof, and the cases that +//! must NOT get one. +//! +//! Every positive test here fails against the pre-#7034 collector (the local +//! was denied with rule 2 / the call was never a candidate at all), and every +//! negative test fails if the corresponding guard is deleted — the guards are +//! named in each test's doc so the sabotage is reproducible. + +use super::*; +use crate::collectors::PtrShapeLocal; +use perry_hir::types::{FuncId, Type}; +use perry_hir::{ClassField, Param}; + +fn field(name: &str) -> ClassField { + ClassField { + name: name.to_string(), + key_expr: None, + ty: Type::Number, + init: None, + is_private: false, + is_readonly: false, + decorators: Vec::new(), + } +} + +fn class_c() -> Class { + Class { + id: 0, + name: "C".to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: vec![field("x")], + constructor: None, + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + computed_members: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + is_nested: false, + alloc_width_hint: 0, + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + } +} + +fn new_c() -> Expr { + Expr::New { + class_name: "C".to_string(), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + } +} + +fn let_c(id: u32, name: &str) -> Stmt { + Stmt::Let { + id, + name: name.to_string(), + ty: Type::Any, + mutable: false, + init: Some(new_c()), + } +} + +fn store_x(id: u32) -> Stmt { + Stmt::Expr(Expr::PropertySet { + object: Box::new(Expr::LocalGet(id)), + property: "x".to_string(), + value: Box::new(Expr::Number(1.0)), + }) +} + +fn function(id: FuncId, name: &str, body: Vec) -> Function { + Function { + id, + name: name.to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Any, + body, + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } +} + +/// A module with class `C` and the given functions, run through the real +/// `collect_module_dispatch_facts` so the barrier flags are the real ones. +fn facts_for(functions: Vec) -> (ModuleDispatchFacts, Class) { + facts_for_classes(Vec::new(), functions) +} + +/// `facts_for` plus extra declared classes. Needed whenever a test's intended +/// guard could be masked by "the class is not declared in this module", which +/// denies a fact for a different reason and would make the test vacuous. +fn facts_for_classes(extra: Vec, functions: Vec) -> (ModuleDispatchFacts, Class) { + let mut hir = Module::new("t"); + hir.classes.push(class_c()); + hir.classes.extend(extra); + hir.functions = functions; + (super::super::collect_module_dispatch_facts(&hir), class_c()) +} + +/// A second admissible class, identical to `C` but for its identity. +fn class_d() -> Class { + let mut d = class_c(); + d.id = 1; + d.name = "D".to_string(); + d +} + +fn classes_of(c: &Class) -> HashMap { + let mut m = HashMap::new(); + m.insert("C".to_string(), c); + m +} + +fn promote( + stmts: &[Stmt], + classes: &HashMap, + facts: &ModuleDispatchFacts, +) -> HashMap { + super::super::ptr_shape::collect_shape_proven_ptr_locals( + stmts, + &HashSet::new(), + &HashMap::new(), + classes, + facts, + &HashSet::new(), + ) +} + +// ── Half 1: the producer-side return exemption ───────────────────────────── + +/// `const o = new C(); o.x = 1; return o;` — the accumulator idiom. Before +/// #7034 §4 this was denied with rule 2 ("returned from this function"). +/// +/// Sabotage: delete the `!self.in_closure` / `Expr::LocalGet` arm in +/// `ptr_shape.rs`'s `Stmt::Return` and this fails. +#[test] +fn returned_local_is_promoted() { + let c = class_c(); + let classes = classes_of(&c); + let (facts, _) = facts_for(Vec::new()); + let stmts = vec![ + let_c(1, "acc"), + store_x(1), + Stmt::Return(Some(Expr::LocalGet(1))), + ]; + let promoted = promote(&stmts, &classes, &facts); + assert!( + promoted.contains_key(&1), + "a contained local whose only escape is `return o` must be promoted" + ); +} + +/// The exemption is for the BARE form only. `return { wrapper: o }` (here the +/// generic container shape: an array literal) still embeds the object in a +/// value whose other references the walk has not bounded. +/// +/// Sabotage: widen the `Stmt::Return` arm to exempt any return and this fails. +#[test] +fn return_of_a_container_holding_the_local_still_escapes() { + let c = class_c(); + let classes = classes_of(&c); + let (facts, _) = facts_for(Vec::new()); + let stmts = vec![ + let_c(1, "wrapped"), + store_x(1), + Stmt::Return(Some(Expr::Array(vec![Expr::LocalGet(1)]))), + ]; + assert!( + !promote(&stmts, &classes, &facts).contains_key(&1), + "`return [o]` must still disqualify — the array outlives the frame" + ); +} + +/// A `return o` inside a CLOSURE body is not a terminator for the enclosing +/// function's local: the closure can be invoked at an unbounded later time, +/// after the enclosing body has gone on using `o`. +/// +/// Sabotage: drop the `in_closure` tracking and this fails. +#[test] +fn return_inside_a_closure_body_is_not_exempt() { + let c = class_c(); + let classes = classes_of(&c); + let (facts, _) = facts_for(Vec::new()); + let stmts = vec![ + let_c(1, "escapes"), + Stmt::Expr(Expr::Closure { + func_id: 99, + params: Vec::new(), + return_type: Type::Any, + // Deliberately NOT in `captures`: the point of the guard is to + // hold even where capture analysis has not marked the reference. + body: vec![Stmt::Return(Some(Expr::LocalGet(1)))], + captures: Vec::new(), + mutable_captures: Vec::new(), + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_async: false, + is_generator: false, + is_strict: false, + }), + ]; + assert!( + !promote(&stmts, &classes, &facts).contains_key(&1), + "a closure body's `return o` must not license the enclosing local" + ); +} + +// ── Half 2: the caller-side fact ─────────────────────────────────────────── + +/// The producer proven above becomes a fact, and `const r = producer()` is +/// then a rule-1 seed: `r.x` in the caller lowers guard-free. +/// +/// Sabotage: return `None` from `producer_return_class`, or drop the +/// `return_seeded` arm in `ptr_shape.rs`'s `Stmt::Let`, and this fails. +#[test] +fn call_to_a_return_shape_producer_is_provenance() { + let producer = function( + 7, + "make", + vec![ + let_c(1, "acc"), + store_x(1), + Stmt::Return(Some(Expr::LocalGet(1))), + ], + ); + let (facts, c) = facts_for(vec![producer]); + assert_eq!( + facts.return_shape_class(7), + Some("C"), + "the producer must carry a return-shape fact" + ); + + let classes = classes_of(&c); + let caller = vec![ + Stmt::Let { + id: 20, + name: "r".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Call { + callee: Box::new(Expr::FuncRef(7)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }), + }, + store_x(20), + ]; + let promoted = promote(&caller, &classes, &facts); + let fact = promoted + .get(&20) + .expect("the call result must be a Ptr candidate"); + assert_eq!(fact.class_name, "C"); + assert!( + fact.numeric_fields.is_empty(), + "a call-seeded candidate must never claim numeric fields: the \ + producer's own stores are outside this region" + ); +} + +/// `return new C()` directly is fresh by construction — no body proof needed. +#[test] +fn direct_new_return_is_a_fact() { + let (facts, _) = facts_for(vec![function(3, "mk", vec![Stmt::Return(Some(new_c()))])]); + assert_eq!(facts.return_shape_class(3), Some("C")); +} + +/// A producer that can fall off the end returns `undefined` on that path; a +/// caller treating the result as a proven `C` would load a field off it. +/// +/// Sabotage: delete the `f.body.last()` check and this fails. +#[test] +fn producer_that_can_fall_through_gets_no_fact() { + let (facts, _) = facts_for(vec![function( + 4, + "maybe", + vec![Stmt::If { + condition: Expr::Bool(true), + then_branch: vec![Stmt::Return(Some(new_c()))], + else_branch: None, + }], + )]); + assert_eq!( + facts.return_shape_class(4), + None, + "an implicit `return undefined` path must deny the fact" + ); +} + +/// A bare `return;` is the same hazard, spelled explicitly. +#[test] +fn bare_return_denies_the_fact() { + let (facts, _) = facts_for(vec![function( + 5, + "maybe2", + vec![ + Stmt::If { + condition: Expr::Bool(true), + then_branch: vec![Stmt::Return(None)], + else_branch: None, + }, + Stmt::Return(Some(new_c())), + ], + )]); + assert_eq!(facts.return_shape_class(5), None); +} + +/// The object must be FRESH. A producer that hands back a value it was given +/// (or read from anywhere else) is aliased, so no fact — this is the +/// `function get() { return CACHE; }` hazard in its smallest form: the +/// returned local is also passed to another function. +/// +/// Sabotage: skip the `collect_shape_proven_ptr_locals` body proof and accept +/// any `return ` — this fails. +#[test] +fn producer_whose_local_also_escapes_gets_no_fact() { + let (facts, _) = facts_for(vec![function( + 6, + "leaky", + vec![ + let_c(1, "o"), + // `stash(o)` — an alias the caller-side proof could not bound. + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::LocalGet(50)), + args: vec![Expr::LocalGet(1)], + type_args: Vec::new(), + byte_offset: 0, + }), + Stmt::Return(Some(Expr::LocalGet(1))), + ], + )]); + assert_eq!( + facts.return_shape_class(6), + None, + "a producer that also leaks the object must not carry a fact" + ); +} + +/// Returns that disagree on the class carry no fact. +/// +/// `D` is DECLARED in the module on purpose. With it undeclared the fact would +/// also be denied — by "the class is not in this module's table" — so the test +/// would pass with the agreement check deleted and would be asserting nothing. +/// +/// Sabotage: change the `Some(_) => return None` arm in `producer_return_class` +/// to accept a disagreeing class and this fails. +#[test] +fn disagreeing_return_classes_get_no_fact() { + let other = Expr::New { + class_name: "D".to_string(), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }; + let (facts, _) = facts_for_classes( + vec![class_d()], + vec![function( + 8, + "two", + vec![ + Stmt::If { + condition: Expr::Bool(true), + then_branch: vec![Stmt::Return(Some(new_c()))], + else_branch: None, + }, + Stmt::Return(Some(other)), + ], + )], + ); + assert_eq!(facts.return_shape_class(8), None); + + // The control: the SAME module shape with both returns agreeing on `D` + // does carry a fact. Without this, "denied" could still be an artifact of + // the fixture rather than of the disagreement. + let (facts, _) = facts_for_classes( + vec![class_d()], + vec![function( + 9, + "one", + vec![Stmt::Return(Some(Expr::New { + class_name: "D".to_string(), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }))], + )], + ); + assert_eq!(facts.return_shape_class(9), Some("D")); +} + +/// An async producer's locals are boxed into one shared cell by the +/// async-to-generator transform, and its `return` is not a terminator in the +/// sense rule 2 needs. +#[test] +fn async_producer_gets_no_fact() { + let mut f = function(9, "amk", vec![Stmt::Return(Some(new_c()))]); + f.is_async = true; + let (facts, _) = facts_for(vec![f]); + assert_eq!(facts.return_shape_class(9), None); +} + +/// Rule 5: any §5.2 barrier in the module denies every return-shape fact, +/// exactly as it denies every local. +#[test] +fn module_barrier_denies_every_fact() { + let mut hir = Module::new("t"); + hir.classes.push(class_c()); + hir.functions = vec![function(10, "mk", vec![Stmt::Return(Some(new_c()))])]; + // `delete o.x` anywhere in the module is a shape barrier. + hir.init = vec![Stmt::Expr(Expr::Delete(Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(1)), + property: "x".to_string(), + byte_offset: 0, + })))]; + let facts = super::super::collect_module_dispatch_facts(&hir); + assert!(facts.has_shape_barrier_sites()); + assert_eq!(facts.return_shape_class(10), None); +} + +/// Only a direct `Expr::FuncRef` callee names a statically-known function. +/// A computed callee could be rebound between the fact and the call. +#[test] +fn indirect_callee_is_not_seeded() { + let (facts, c) = facts_for(vec![function(11, "mk", vec![Stmt::Return(Some(new_c()))])]); + assert_eq!(facts.return_shape_class(11), Some("C")); + let classes = classes_of(&c); + let caller = vec![ + Stmt::Let { + id: 30, + name: "r".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Call { + // A closure value in a local, not a FuncRef. + callee: Box::new(Expr::LocalGet(31)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }), + }, + store_x(30), + ]; + assert!( + !promote(&caller, &classes, &facts).contains_key(&30), + "an indirect call must not be a provenance seed" + ); +} + +/// A call-seeded candidate is still subject to rules 2-4: escaping it in the +/// caller denies it exactly as escaping a `new`-seeded one does. +#[test] +fn call_seeded_candidate_still_obeys_containment() { + let (facts, c) = facts_for(vec![function(12, "mk", vec![Stmt::Return(Some(new_c()))])]); + let classes = classes_of(&c); + let caller = vec![ + Stmt::Let { + id: 40, + name: "r".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Call { + callee: Box::new(Expr::FuncRef(12)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }), + }, + // `r.nope` is not a declared field of `C`. + Stmt::Expr(Expr::PropertySet { + object: Box::new(Expr::LocalGet(40)), + property: "nope".to_string(), + value: Box::new(Expr::Number(1.0)), + }), + ]; + assert!( + !promote(&caller, &classes, &facts).contains_key(&40), + "an undeclared-property write must deny a call-seeded candidate too" + ); +} + +/// A `Param`-carrying producer is fine; this pins that the fact does not +/// accidentally depend on an empty parameter list. +#[test] +fn producer_with_params_still_gets_a_fact() { + let mut f = function(13, "mk1", vec![Stmt::Return(Some(new_c()))]); + f.params = vec![Param { + id: 100, + name: "n".to_string(), + ty: Type::Number, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }]; + let (facts, _) = facts_for(vec![f]); + assert_eq!(facts.return_shape_class(13), Some("C")); +} + +/// GC guard. A producer whose declared return type is definitely-non-pointer +/// would leave the caller's binding without a shadow-stack slot +/// (`collect_pointer_typed_locals` drops it), so a promoted `Ptr` local +/// there would be an UNROOTED object pointer under an evacuating minor. +/// Perry does not check annotations, so the fact must stand down. +/// +/// Sabotage: delete the `is_definitely_non_pointer(&f.return_type)` check and +/// this fails. +#[test] +fn non_pointer_return_type_denies_the_fact() { + for ty in [ + Type::Number, + Type::Int32, + Type::Boolean, + Type::Void, + Type::Union(vec![Type::Number, Type::Boolean]), + ] { + let mut f = function(14, "lying", vec![Stmt::Return(Some(new_c()))]); + f.return_type = ty.clone(); + let (facts, _) = facts_for(vec![f]); + assert_eq!( + facts.return_shape_class(14), + None, + "a `{ty:?}`-annotated producer must not carry a return-shape fact" + ); + } + // The complement: a union that CAN hold a pointer keeps the slot, so the + // guard must not over-reject. + let mut f = function(15, "honest", vec![Stmt::Return(Some(new_c()))]); + f.return_type = Type::Union(vec![Type::Named("C".to_string()), Type::Void]); + let (facts, _) = facts_for(vec![f]); + assert_eq!(facts.return_shape_class(15), Some("C")); +} + +/// The proof's inputs must not be able to differ between the module pre-pass +/// (which sees `collect_boxed_vars(&f.body)` and an empty module-global map) +/// and the per-region pass (which sees the module-wide union). A local boxed +/// by a closure capture inside the producer must be rejected by BOTH. +#[test] +fn boxed_producer_local_gets_no_fact() { + let body = vec![ + let_c(1, "o"), + // A closure that mutably captures `o` — boxes it. + Stmt::Expr(Expr::Closure { + func_id: 98, + params: Vec::new(), + return_type: Type::Any, + body: vec![store_x(1)], + captures: Vec::new(), + mutable_captures: vec![1], + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_async: false, + is_generator: false, + is_strict: false, + }), + Stmt::Return(Some(Expr::LocalGet(1))), + ]; + let (facts, _) = facts_for(vec![function(16, "boxy", body)]); + assert_eq!(facts.return_shape_class(16), None); +} diff --git a/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs b/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs index 191ca2c860..72846fba94 100644 --- a/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs +++ b/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs @@ -79,6 +79,13 @@ pub struct ModuleDispatchFacts { /// module therefore disables proven-`this` clones **that contain a /// `this.field` write**; read-only clones are unaffected. freeze_barrier_sites: bool, + /// Representation-selection Phase 3b, #7034 §4: **return-shape facts**. + /// `FuncId` -> the exact class every value this module-level function can + /// return. Populated only for functions that provably hand back a FRESH, + /// UNALIASED object of one class on every return path + /// (`collectors/ptr_shape_returns.rs`); a call to such a function is then + /// a rule-1 provenance seed exactly as `new C(...)` is. + return_shape_functions: HashMap, } impl Default for ModuleDispatchFacts { @@ -91,6 +98,7 @@ impl Default for ModuleDispatchFacts { shape_barrier_sites: true, numarray_prototype_index_barriers: true, freeze_barrier_sites: true, + return_shape_functions: HashMap::new(), } } } @@ -155,6 +163,16 @@ impl ModuleDispatchFacts { pub(crate) fn has_opaque_prototype_mutation(&self) -> bool { self.opaque_prototype_mutation } + + /// Representation-selection Phase 3b, #7034 §4: the exact class a call to + /// module function `func_id` provably returns, when that call is a rule-1 + /// provenance seed. `None` for every other function — including every + /// function in a module that carries a §5.2 barrier. + pub(crate) fn return_shape_class(&self, func_id: u32) -> Option<&str> { + self.return_shape_functions + .get(&func_id) + .map(String::as_str) + } } /// Scan a whole module — top-level init, every function, and every class body @@ -167,6 +185,7 @@ pub fn collect_module_dispatch_facts(hir: &Module) -> ModuleDispatchFacts { shape_barrier_sites: false, numarray_prototype_index_barriers: false, freeze_barrier_sites: false, + return_shape_functions: HashMap::new(), }; note_stmts(&hir.init, &mut facts); @@ -200,6 +219,15 @@ pub fn collect_module_dispatch_facts(hir: &Module) -> ModuleDispatchFacts { } } + // Representation-selection Phase 3b, #7034 §4. Computed LAST, and read + // through the partially-built `facts` — the barrier flags above must + // already be final, because a §5.2 barrier anywhere in the module denies + // every return-shape fact. `facts.return_shape_functions` is still empty + // while this runs, so the per-function proof (which re-enters + // `collect_shape_proven_ptr_locals`) can never seed itself recursively. + facts.return_shape_functions = + super::ptr_shape_returns::collect_return_shape_functions(&facts, hir); + facts } @@ -614,6 +642,7 @@ mod tests { shape_barrier_sites: false, numarray_prototype_index_barriers: false, freeze_barrier_sites: false, + return_shape_functions: HashMap::new(), } } diff --git a/test-files/test_gap_repsel_return_shape.ts b/test-files/test_gap_repsel_return_shape.ts new file mode 100644 index 0000000000..4c1c2a92d7 --- /dev/null +++ b/test-files/test_gap_repsel_return_shape.ts @@ -0,0 +1,229 @@ +// Representation-selection Phase 3b, #7034 §4: return-shape facts +// (RFC docs/representation-selection-rfc.md §5.5-§5.7, +// collectors/ptr_shape_returns.rs). +// +// Behavioural guard for the two halves of the proof. Both must be BYTE-EXACT +// against Node — an optimization that changes an observable answer is a +// miscompile, and every case below is one the pre-#7034 compiler left on the +// guarded protocol, so a divergence here is attributable. +// +// The promotions this file is *about* are asserted structurally, not here: +// `perry test-files/test_gap_repsel_return_shape.ts --opt-report` must list +// `producedRec`, `shaped`, `survivor`, `acc`, `poisoned` and friends as +// `Ptr`. A green run of this file with zero promotions would be a +// vacuous pass (#7024/#7025), so the count is checked in review, not inferred. +// +// Covered: +// 1. producer-side: a contained local whose only escape is `return o`, +// 2. caller-side: `const r = producer(...)` as rule-1 provenance, +// 3. object-literal producers (`return { k: v }` -> __AnonShape_*), +// 4. the numeric-field STAND-DOWN: NaN/Infinity/-0 stored by the producer +// into a field whose constructor store was a plain finite number, read +// back by the caller — the caller never saw the producer's store, +// 5. GC movement between the call and the field reads (the tagged-at-rest +// slot must be re-derived and rewritten, RFC §5.6), +// 6. `finally` running after the return value is computed, +// 7. producers that must NOT carry a fact: an aliased cache, a +// fall-through-to-undefined path, an indirect callee. + +class Rec { + id: number; + name: string; + score: number; + constructor(id: number, name: string, score: number) { + this.id = id; + this.name = name; + this.score = score; + } + bump(by: number): number { + this.score = this.score + by; + return this.score; + } +} + +// 1 + 2. The accumulator idiom: contained local, single escape is the return. +function producedRec(i: number): Rec { + const r = new Rec(i, "r" + i, 0); + r.score = r.id * 1.5; + r.score = r.score + 0.25; + return r; +} + +// Same, but with a method call on the returned local. `Rec.bump` is denied by +// rule 3 (this-flow) today for reasons unrelated to the return position, so +// this one stays on the guarded protocol — it is here to pin that the two +// paths still agree. +function bumpedRec(i: number): Rec { + const r = new Rec(i, "b" + i, 0); + r.bump(i * 0.5); + return r; +} + +function consumeRec(i: number): string { + const r = producedRec(i); + r.score = r.score + 1; + return r.name + ":" + r.score.toFixed(3) + ":" + r.id; +} + +// A loop-carried accumulator returned at the end — the `totalsRow` shape. +function foldRecs(n: number): Rec { + const acc = new Rec(0, "acc", 0); + for (let i = 1; i <= n; i++) { + acc.id = acc.id + i; + acc.score = acc.score + i * 0.5; + } + acc.name = "acc" + acc.id; + return acc; +} + +// 3. Object-literal producer: `return { ... }` lowers to __AnonShape_*. +interface Shaped { + key: string; + value: number; +} + +function shapeOne(i: number): Shaped { + return { key: "k" + i, value: i * 2 }; +} + +function readShaped(i: number): string { + const s = shapeOne(i); + return s.key + "=" + (s.value + 1); +} + +// 4. Values the caller's region never saw stored. The constructor stores a +// plain finite number into `v`; the PRODUCER then stores a non-plain-finite +// one. The caller must not fold the constructor's store into its read — its +// bare load has to survive NaN/Infinity/-0 bit patterns, which is why a +// call-seeded candidate never claims `numeric_fields` (the exhaustive +// reachable-store proof does not reach into the producer). The unit test +// `call_to_a_return_shape_producer_is_provenance` pins the claim itself; +// this pins the observable answer. +class Mixed { + v: number; + tag: string; + constructor() { + this.v = 1; + this.tag = "m"; + } +} + +function makeMixed(kind: number): Mixed { + const m = new Mixed(); + m.v = 2; + if (kind === 1) { + m.v = NaN; + } else if (kind === 2) { + m.v = Infinity; + } else if (kind === 3) { + m.v = -0; + } + return m; +} + +function readMixed(kind: number): string { + const m = makeMixed(kind); + // Number context on a slot the caller's region never saw stored. + return m.tag + "|" + m.v + "|" + (m.v + 1) + "|" + Object.is(m.v, -0); +} + +// 5. GC movement between the provenance call and the field reads. +// +// The churn must ESCAPE, or scalar replacement deletes it and the arena never +// grows: measured, a 60 000-iteration loop over a non-escaping literal drives +// ZERO collections, which would make every GC arm inert against this file +// (#6942/#6946 — the failure mode `scripts/gc_repsel_matrix.sh` exists to +// report). The sink is module-level and periodically dropped, so the +// allocations are genuinely live and then genuinely dead. Keep the budget in +// sync with the matrix's liveness column. +let churnSink: unknown[] = []; + +function churn(i: number): void { + churnSink.push({ i: i, s: "c" + (i & 1023), a: [i, i + 1] }); + if (churnSink.length > 4096) { + churnSink = []; + } +} + +function survivesGc(n: number): string { + // A CALL-SEEDED (#7034 §4) local: the caller's bound slot is the only + // rewritable root for this object, since the producer's frame is gone. + const survivor = producedRec(7); + let sink = 0; + for (let i = 0; i < n; i++) { + churn(i); + // Read AFTER the allocation safepoint, every iteration. If an evacuating + // scavenge moved `survivor` and the bound slot was not rewritten — or the + // raw pointer was CSE'd across the safepoint — this observes a stale + // address. + sink = sink + survivor.id; + } + return survivor.name + "/" + survivor.score.toFixed(2) + "/" + sink; +} + +// 6. `finally` runs after the return value is computed but before the caller +// resumes — the ordering the return exemption's soundness argument rests on. +function returnThenFinally(): string { + const o = new Rec(1, "fin", 10); + const seen: string[] = []; + try { + o.score = 20; + return o.name + ":" + o.score + ":" + seen.length; + } finally { + seen.push("ran"); + o.score = 999; + } +} + +// 7a. An aliased cache: `return CACHE` is not fresh, so no fact — and the +// caller must observe mutations made through the other alias. +let CACHE: Rec | null = null; +function getCached(): Rec { + if (CACHE === null) { + CACHE = new Rec(100, "cached", 0); + } + return CACHE; +} + +// 7b. A producer that can fall through to `undefined`. +function maybeRec(b: boolean): Rec | undefined { + if (b) { + return new Rec(5, "maybe", 5); + } + return undefined; +} + +// 7c. Indirect callee — the binding is a value, not a statically-known name. +const indirect: (i: number) => Rec = producedRec; + +const out: string[] = []; + +out.push(consumeRec(3)); +out.push(consumeRec(0)); +const b = bumpedRec(6); +out.push("bumped:" + b.name + ":" + b.score); +out.push(foldRecs(10).name + "/" + foldRecs(10).score); +out.push(readShaped(4)); +out.push(readMixed(0)); +out.push(readMixed(1)); +out.push(readMixed(2)); +out.push(readMixed(3)); +out.push(survivesGc(120000)); +out.push(returnThenFinally()); + +const c1 = getCached(); +c1.id = 42; +const c2 = getCached(); +out.push("cache:" + c2.id + ":" + c2.name); + +const m1 = maybeRec(true); +out.push("maybe:" + (m1 === undefined ? "none" : m1.name + m1.score)); +const m2 = maybeRec(false); +out.push("maybe:" + (m2 === undefined ? "none" : "some")); + +const ind = indirect(9); +out.push("indirect:" + ind.name + ":" + ind.score); + +for (const line of out) { + console.log(line); +} diff --git a/test-parity/gc_repsel_corpus.txt b/test-parity/gc_repsel_corpus.txt index 911048b402..4501ad364f 100644 --- a/test-parity/gc_repsel_corpus.txt +++ b/test-parity/gc_repsel_corpus.txt @@ -30,6 +30,15 @@ test_gap_repsel_canonical_str_locals test_gap_repsel_ptr_shape_locals test_gap_repsel_ptr_shape_barriers +# --- Phase 3b / #7034 §4: return-shape facts -------------------------------- +# Both halves of the return escape: producer-side locals whose only escape is +# `return o`, and caller-side bindings seeded by a return-shape-carrying call. +# `survivesGc` deliberately holds a call-seeded local live across 60 000 +# escaping allocations, so the evacuating arms have something to move under it +# — the caller's bound slot is the only rewritable root for that object, which +# is exactly the claim this phase's GC contract makes. +test_gap_repsel_return_shape + # --- Phase 4a / 4a.3: Ptr numeric arrays (#6915, #6916) ----------- test_gap_repsel_p4a_holes_axis test_gap_repsel_p4a_inline_tiers