Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/11774-scope-ctx-switch-case-dominance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **Fixed a SIGSEGV reading a captured `let` from a later `switch` case (#11771).** The scope-context grouping (#11710) checks that a binding's home dominates every reference by testing statement numbers against the end of the home's statement list. That end is one past the list's last statement, i.e. the number of the next statement, but the test was inclusive. For a `switch` case, the next statement is the first statement of the following case: it is in the binding's scope but skips the `let`, so `case 1: let x = …; (() => x++)(); case 2: return typeof x` grouped `x`, and entering at case 2 dereferenced the never-allocated scope root (`TAG_UNDEFINED`) as a pointer. The bound is now exclusive (`crates/perry-codegen/src/scope_env/analysis.rs`), so such a binding keeps its entry-initialized per-binding cell and the skipped read is `undefined` again, as the #4926 regression test requires. Unit tests in `scope_env/tests.rs` pin both the refusal and the in-list control.
12 changes: 9 additions & 3 deletions crates/perry-codegen/src/scope_env/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@
//! reference inside a closure counts at the statement that creates the
//! closure). A statement list's subtree occupies a contiguous number range, so
//! "every reference sits in the home statement or in a later sibling of it" is
//! the interval test `home.num <= ref <= end(home list)`.
//! the interval test `home.num <= ref < end(home list)`, where `end` is one
//! past the list's last statement number. That bound is the NEXT statement's
//! number: after a switch case it is the first statement of the following
//! case, which shares the binding's scope but not the home's activation, so an
//! inclusive test grouped a binding that case reads before its `let` ever ran
//! (#11771).

use std::collections::{BTreeSet, HashMap, HashSet};

Expand Down Expand Up @@ -47,6 +52,7 @@ pub(super) struct BodyFacts {
pub decls: HashMap<u32, Vec<Decl>>,
/// Lowest and highest statement number referencing the id.
pub refs: HashMap<u32, (u32, u32)>,
/// One past the highest statement number in each list (exclusive).
pub list_end: HashMap<u32, u32>,
pub preallocs: Vec<PreallocStmt>,
}
Expand All @@ -62,11 +68,11 @@ impl BodyFacts {
}
let pos = home.pos?;
let end = *self.list_end.get(&pos.token)?;
if decls.iter().any(|d| d.num < home.num || d.num > end) {
if decls.iter().any(|d| d.num < home.num || d.num >= end) {
return None;
}
if let Some(&(lo, hi)) = self.refs.get(&id) {
if lo < home.num || hi > end {
if lo < home.num || hi >= end {
return None;
}
}
Expand Down
72 changes: 72 additions & 0 deletions crates/perry-codegen/src/scope_env/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,78 @@ fn the_map_follows_the_group_statements() {
assert_eq!(map.members(10), &[10, 12]);
}

/// `switch (2) { case 1: <case1>; case 2: return typeof v7 }` — the
/// #4926 / #11771 shape: case 2 is in `v7`'s scope, but entering at case 2
/// skips case 1's `let`, so the home never ran there.
fn switch_read_from_next_case(case1: Vec<Stmt>) -> Vec<Stmt> {
vec![Stmt::Switch {
discriminant: Expr::Number(2.0),
cases: vec![
perry_hir::SwitchCase {
test: Some(Expr::Number(1.0)),
body: case1,
},
perry_hir::SwitchCase {
test: Some(Expr::Number(2.0)),
body: vec![Stmt::Return(Some(Expr::TypeOf(Box::new(Expr::LocalGet(
7,
)))))],
},
],
}]
}

#[test]
fn the_first_statement_of_the_next_case_is_outside_the_home_list() {
// The statement right after the home list is numbered exactly `end`; an
// inclusive bound let it through and case 2 read a scope object that was
// never allocated (#11771).
let mut module = module_with(switch_read_from_next_case(vec![
let_num(7, 5.0),
Stmt::Expr(mutating_closure(100, &[7])),
]));
group_scope_boxes(&mut module);
assert!(preallocs(&module.functions[0].body).is_empty());
let boxed = crate::codegen::boxed_locals::collect_module_boxed_vars(&module);
assert!(ScopeMap::build(&module, &boxed, &Default::default()).is_empty());
}

#[test]
fn a_lowered_case_preallocation_read_from_the_next_case_keeps_its_cell() {
// The HIR lowering's own shape: the case body opens with the binding's
// preallocation. The map must still refuse it, so the binding keeps its
// entry-initialized per-binding cell and the skipped read is `undefined`.
let mut module = module_with(switch_read_from_next_case(vec![
Stmt::PreallocateBoxes(vec![7]),
let_num(7, 5.0),
Stmt::Expr(mutating_closure(100, &[7])),
]));
group_scope_boxes(&mut module);
let boxed = crate::codegen::boxed_locals::collect_module_boxed_vars(&module);
assert!(boxed.contains(&7));
let map = ScopeMap::build(&module, &boxed, &Default::default());
assert!(map.slot(7).is_none(), "{map:?}");
}

#[test]
fn a_reference_in_the_last_statement_of_the_home_list_still_groups() {
// Control for the bound: the home list's own last statement is inside it.
let mut module = module_with(switch_read_from_next_case(vec![
Stmt::PreallocateBoxes(vec![7]),
let_num(7, 5.0),
Stmt::Expr(mutating_closure(100, &[7])),
Stmt::Return(Some(Expr::LocalGet(7))),
]));
// Drop case 2's read: only the in-list references remain.
if let Stmt::Switch { cases, .. } = &mut module.functions[0].body[0] {
cases[1].body = vec![Stmt::Return(None)];
}
group_scope_boxes(&mut module);
let boxed = crate::codegen::boxed_locals::collect_module_boxed_vars(&module);
let map = ScopeMap::build(&module, &boxed, &Default::default());
assert!(map.slot(7).is_some(), "{map:?}");
}

fn compile_ir(module: &Module) -> String {
let options = crate::CompileOptions {
emit_ir_only: true,
Expand Down
Loading