Skip to content

Commit c252d71

Browse files
author
Ralph Küpper
committed
fix(repsel): contain guarded for-of element facts
1 parent ae34f5d commit c252d71

2 files changed

Lines changed: 300 additions & 7 deletions

File tree

crates/perry-codegen/src/collectors/ptr_shape_elements.rs

Lines changed: 134 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,11 @@
4141
//! `A` **dense** and monomorphic for its whole lifetime.
4242
//! * **E3 — array containment.** Every *other* use of `A` is an in-bounds
4343
//! element read (E5), a `.length` read, or `return A`. The return exemption
44-
//! is #7034 §4's, unchanged and for the same reason. Anything else — call
45-
//! argument, closure capture, reassignment, `IndexSet`, an unrecognised
46-
//! array method, being an element of another container — disqualifies `A`.
44+
//! is #7034 §4's, unchanged and for the same reason. The one conditional
45+
//! escape is the compiler-generated `ArrayIterationPatched` guard described
46+
//! below. Anything else — call argument, closure capture, reassignment,
47+
//! `IndexSet`, an unrecognised array method, being an element of another
48+
//! container — disqualifies `A`.
4749
//! * **E4 — class admissibility.** `C` passes the same `chain_admissible`
4850
//! gate rule 1 applies to a `new C(...)` local, and the module-wide rule-5
4951
//! barrier scan is clear.
@@ -58,10 +60,17 @@
5860
//! `A[i]` can be `undefined`, and a guard-free fixed-offset load masks a
5961
//! NaN-boxed `undefined` into a wild pointer.
6062
//!
61-
//! `for (const r of A)` desugars to exactly the E5 shape
63+
//! `for (const r of A)` has an E5 index arm
6264
//! (`lower/stmt_loops.rs::lazy_or_index_elem` — a `__idx` local, `__idx <
63-
//! __arr.length`, `Let r = IndexGet(__arr, __idx)`), so the iterator form is
64-
//! covered by the indexed proof rather than by a second one.
65+
//! __arr.length`, `Let r = IndexGet(__arr, __idx)`) behind the
66+
//! `ArrayIterationPatched` runtime guard. Its lazy arm passes `A` to
67+
//! `GetIterator`, which is ordinarily an E3 escape: a custom iterator can
68+
//! reshape an element before returning. The index-arm facts remain sound only
69+
//! when that top-level guard is the last use of both the array and every
70+
//! element-group member. Then the mutating lazy arm and the proven index arm
71+
//! are mutually exclusive, and no fact crosses their join. A nested guard is
72+
//! refused because a loop backedge could bring the mutated array to an
73+
//! earlier proven access on the next iteration.
6574
//!
6675
//! ## What the facts are used for
6776
//!
@@ -314,14 +323,15 @@ pub(crate) fn collect_element_shape_facts(
314323
// E3/E5: the array use walk.
315324
let mut walk = ArrayWalk {
316325
roots: &array_roots,
326+
alias_edges: &alias_edges,
317327
disqualified: HashSet::new(),
318328
pushes: HashMap::new(),
319329
reads: Vec::new(),
320330
idx_writes: HashMap::new(),
321331
bounded: Vec::new(),
322332
in_closure: false,
323333
};
324-
walk.walk_stmts(stmts);
334+
walk.walk_region_stmts(stmts);
325335
let ArrayWalk {
326336
mut disqualified,
327337
pushes,
@@ -487,6 +497,7 @@ struct ReadSite {
487497

488498
struct ArrayWalk<'a> {
489499
roots: &'a HashMap<u32, u32>,
500+
alias_edges: &'a [(u32, u32)],
490501
disqualified: HashSet<u32>,
491502
pushes: HashMap<u32, Vec<PushValue>>,
492503
reads: Vec<ReadSite>,
@@ -520,6 +531,110 @@ impl<'a> ArrayWalk<'a> {
520531
}
521532
}
522533

534+
/// Walk the region's outer statement list, where a one-shot temporal
535+
/// boundary can be proved. Nested statement lists deliberately use
536+
/// `walk_stmts`: admitting a guarded escape inside a loop would let its
537+
/// lazy arm reshape the array before a backedge reaches an earlier fact.
538+
fn walk_region_stmts(&mut self, stmts: &[Stmt]) {
539+
for (index, stmt) in stmts.iter().enumerate() {
540+
if self.walk_terminal_array_iteration_guard(stmt, &stmts[index + 1..]) {
541+
continue;
542+
}
543+
self.walk_stmt(stmt);
544+
}
545+
}
546+
547+
/// Admit the compiler-generated guarded `for…of` shape without treating
548+
/// its one `GetIterator(A)` as an unconditional E3 escape.
549+
///
550+
/// A patched iterator is arbitrary code and may transition any element's
551+
/// shape. Consequently the exception is temporal, not semantic: the lazy
552+
/// and index arms must be the final use of the array and of every producer
553+
/// or licensed reader in its element group. Otherwise the whole root is
554+
/// disqualified exactly as a normal bare escape would be.
555+
fn walk_terminal_array_iteration_guard(&mut self, stmt: &Stmt, following: &[Stmt]) -> bool {
556+
let Stmt::If {
557+
condition: Expr::ArrayIterationPatched,
558+
then_branch,
559+
else_branch: Some(index_branch),
560+
} = stmt
561+
else {
562+
return false;
563+
};
564+
let Some(Stmt::Let {
565+
id: iterator_id,
566+
init: Some(Expr::GetIterator(source)),
567+
..
568+
}) = then_branch.first()
569+
else {
570+
return false;
571+
};
572+
let Expr::LocalGet(source_id) = source.as_ref() else {
573+
return false;
574+
};
575+
let Some(root) = self.root_of(*source_id) else {
576+
return false;
577+
};
578+
579+
// Preserve the Let write, but exempt exactly its GetIterator source.
580+
// Every later lazy-arm statement is walked normally, so a second use
581+
// of the array still disqualifies it through the ordinary E3 rules.
582+
self.note_write(*iterator_id);
583+
self.walk_stmts(&then_branch[1..]);
584+
self.walk_stmts(index_branch);
585+
586+
let array_aliases: HashSet<u32> = self
587+
.roots
588+
.iter()
589+
.filter_map(|(id, candidate_root)| (*candidate_root == root).then_some(*id))
590+
.collect();
591+
let mut group_members: HashSet<u32> = self
592+
.pushes
593+
.get(&root)
594+
.into_iter()
595+
.flatten()
596+
.filter_map(|push| match push {
597+
PushValue::Local(id) => Some(*id),
598+
PushValue::Fresh(_) | PushValue::Other => None,
599+
})
600+
.chain(
601+
self.reads
602+
.iter()
603+
.filter_map(|read| (read.root == root).then_some(read.local)),
604+
)
605+
.collect();
606+
// `ptr_shape` promotes immutable aliases with their root. A use of an
607+
// alias after the iterator escape is therefore a use of the same
608+
// potentially-reshaped object and must participate in this boundary.
609+
loop {
610+
let mut changed = false;
611+
for (alias, source) in self.alias_edges {
612+
if group_members.contains(source) {
613+
changed |= group_members.insert(*alias);
614+
}
615+
}
616+
if !changed {
617+
break;
618+
}
619+
}
620+
621+
let lazy_refs = local_refs(then_branch);
622+
let following_refs = local_refs(following);
623+
let lazy_array_uses = lazy_refs
624+
.iter()
625+
.filter(|id| array_aliases.contains(id))
626+
.count();
627+
let unsafe_after_escape = lazy_array_uses != 1
628+
|| lazy_refs.iter().any(|id| group_members.contains(id))
629+
|| following_refs
630+
.iter()
631+
.any(|id| array_aliases.contains(id) || group_members.contains(id));
632+
if unsafe_after_escape {
633+
self.disqualified.insert(root);
634+
}
635+
true
636+
}
637+
523638
fn walk_stmt(&mut self, s: &Stmt) {
524639
match s {
525640
Stmt::Let { id, init, .. } => {
@@ -864,6 +979,18 @@ impl<'a> ArrayWalk<'a> {
864979
}
865980
}
866981

982+
/// Every local referenced from `stmts`, including id-keyed array operations
983+
/// and nested closure bodies. Reuse HIR's exhaustive local-id walker rather
984+
/// than maintaining another list of expression variants in this proof pass.
985+
fn local_refs(stmts: &[Stmt]) -> Vec<u32> {
986+
let mut refs = Vec::new();
987+
let mut visited_closures = HashSet::new();
988+
for stmt in stmts {
989+
perry_hir::collect_local_refs_stmt(stmt, &mut refs, &mut visited_closures);
990+
}
991+
refs
992+
}
993+
867994
/// Statement walker over one region's statement tree.
868995
///
869996
/// It does NOT descend into closure bodies — those live inside `Expr`s, not

crates/perry-codegen/src/collectors/ptr_shape_elements_tests.rs

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,41 @@ fn bounded_loop_cond(idx: u32, condition: Expr, body: Vec<Stmt>) -> Stmt {
165165
}
166166
}
167167

168+
/// The #7761 guarded lowering of `for (const r of rows)`: the lazy arm starts
169+
/// with `GetIterator(rows)`, while the byte-identical fast arm aliases `rows`
170+
/// and performs the ordinary E5 index loop.
171+
fn guarded_for_of(
172+
root: u32,
173+
iterator: u32,
174+
fast_alias: u32,
175+
fast_index: u32,
176+
lazy_tail: Vec<Stmt>,
177+
fast_body: Vec<Stmt>,
178+
) -> Stmt {
179+
let mut lazy = vec![Stmt::Let {
180+
id: iterator,
181+
name: format!("__iterator_{iterator}"),
182+
ty: Type::Any,
183+
mutable: false,
184+
init: Some(Expr::GetIterator(Box::new(Expr::LocalGet(root)))),
185+
}];
186+
lazy.extend(lazy_tail);
187+
Stmt::If {
188+
condition: Expr::ArrayIterationPatched,
189+
then_branch: lazy,
190+
else_branch: Some(vec![
191+
Stmt::Let {
192+
id: fast_alias,
193+
name: format!("__arr_{fast_alias}"),
194+
ty: Type::Array(Box::new(Type::Named("C".to_string()))),
195+
mutable: false,
196+
init: Some(Expr::LocalGet(root)),
197+
},
198+
bounded_loop(fast_index, fast_alias, fast_body),
199+
]),
200+
}
201+
}
202+
168203
/// `const <name> = <arr>[<idx>];`
169204
fn let_elem(id: u32, name: &str, arr: u32, idx: u32) -> Stmt {
170205
let_elem_ty(id, name, arr, idx, Type::Named("C".to_string()))
@@ -850,6 +885,137 @@ fn reads_through_an_array_alias_are_licensed() {
850885
assert!(promoted.contains_key(&2), "and the producer with it");
851886
}
852887

888+
/// #7777: #7761 wrapped a proven-array `for…of` in a runtime iterator-patch
889+
/// branch. The lazy `GetIterator(rows)` is an escape, but it is mutually
890+
/// exclusive with the E5 index arm, and here neither the array nor any group
891+
/// member is used after the join. The producer, explicit indexed reader, and
892+
/// guarded fast-arm reader therefore remain the census fixture's three facts.
893+
///
894+
/// Sabotage: route the outer statement list through ordinary `walk_stmts`, or
895+
/// delete `walk_terminal_array_iteration_guard`, and the GetIterator source
896+
/// voids all three facts exactly as main did in #7777.
897+
#[test]
898+
fn terminal_guarded_for_of_retains_the_index_arm_facts() {
899+
let c = class_c();
900+
let cs = [c];
901+
let classes = classes_of(&cs);
902+
let stmts = vec![
903+
let_arr(1, "rows"),
904+
let_c(2, "row"),
905+
store_x(2),
906+
push(1, Expr::LocalGet(2)),
907+
bounded_loop(4, 1, vec![let_elem(5, "indexed", 1, 4), read_x(5)]),
908+
guarded_for_of(
909+
1,
910+
20,
911+
30,
912+
31,
913+
Vec::new(),
914+
vec![let_elem(32, "iterated", 30, 31), read_x(32)],
915+
),
916+
Stmt::Return(Some(Expr::Number(0.0))),
917+
];
918+
let promoted = promote(&stmts, &classes);
919+
assert!(promoted.contains_key(&2), "the pushed producer");
920+
assert!(promoted.contains_key(&5), "the explicit indexed reader");
921+
assert!(
922+
promoted.contains_key(&32),
923+
"the mutually-exclusive guarded index reader"
924+
);
925+
}
926+
927+
/// A custom iterator receives the actual array and may transition an element
928+
/// before the branch rejoins. A later indexed reader would then consume a
929+
/// stale shape proof, even if the iterator patch were restored before that
930+
/// later loop.
931+
///
932+
/// Sabotage: remove the `following_refs` half of the temporal-boundary check
933+
/// and both guarded and post-join readers promote.
934+
#[test]
935+
fn guarded_for_of_does_not_export_facts_across_its_join() {
936+
let c = class_c();
937+
let cs = [c];
938+
let classes = classes_of(&cs);
939+
let stmts = vec![
940+
let_arr(1, "rows"),
941+
let_c(2, "row"),
942+
push(1, Expr::LocalGet(2)),
943+
guarded_for_of(
944+
1,
945+
20,
946+
30,
947+
31,
948+
Vec::new(),
949+
vec![let_elem(32, "guarded", 30, 31), read_x(32)],
950+
),
951+
bounded_loop(40, 1, vec![let_elem(41, "after", 1, 40), read_x(41)]),
952+
];
953+
assert!(
954+
elements(&stmts, &classes).is_empty(),
955+
"GetIterator can reshape an element before the post-join indexed read"
956+
);
957+
assert!(promote(&stmts, &classes).is_empty());
958+
}
959+
960+
/// The array can reach the producer object before `GetIterator` returns. A
961+
/// direct producer/alias access in the lazy arm is therefore just as unsafe as
962+
/// an array access after the join.
963+
///
964+
/// Sabotage: remove the lazy-arm `group_members` intersection and the producer
965+
/// retains a fact across the arbitrary iterator call.
966+
#[test]
967+
fn guarded_for_of_lazy_arm_cannot_reuse_a_group_member() {
968+
let c = class_c();
969+
let cs = [c];
970+
let classes = classes_of(&cs);
971+
let stmts = vec![
972+
let_arr(1, "rows"),
973+
let_c(2, "row"),
974+
push(1, Expr::LocalGet(2)),
975+
guarded_for_of(
976+
1,
977+
20,
978+
30,
979+
31,
980+
vec![read_x(2)],
981+
vec![let_elem(32, "guarded", 30, 31), read_x(32)],
982+
),
983+
];
984+
assert!(elements(&stmts, &classes).is_empty());
985+
assert!(promote(&stmts, &classes).is_empty());
986+
}
987+
988+
/// A guarded escape inside a loop is not terminal: after the lazy arm mutates
989+
/// an element, the backedge can reach facts from the next iteration. Only the
990+
/// outer region statement list is eligible for the temporal exception.
991+
///
992+
/// Sabotage: invoke the special guard walker from recursive `walk_stmts` and
993+
/// this nested form starts issuing facts.
994+
#[test]
995+
fn a_nested_guarded_for_of_remains_an_escape() {
996+
let c = class_c();
997+
let cs = [c];
998+
let classes = classes_of(&cs);
999+
let stmts = vec![
1000+
let_arr(1, "rows"),
1001+
let_c(2, "row"),
1002+
push(1, Expr::LocalGet(2)),
1003+
Stmt::While {
1004+
condition: Expr::Bool(true),
1005+
body: vec![guarded_for_of(
1006+
1,
1007+
20,
1008+
30,
1009+
31,
1010+
Vec::new(),
1011+
vec![let_elem(32, "guarded", 30, 31), read_x(32)],
1012+
)],
1013+
},
1014+
];
1015+
assert!(elements(&stmts, &classes).is_empty());
1016+
assert!(promote(&stmts, &classes).is_empty());
1017+
}
1018+
8531019
// ── CodeRabbit review reproducers (PR #7149) ───────────────────────────────
8541020

8551021
/// **CodeRabbit 🔴 #1** (`ptr_shape_elements.rs:710`, review of `816a5a3`):

0 commit comments

Comments
 (0)