From d79a30a3ade00e1be930d527873e12974b9657d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 3 Oct 2026 00:54:37 +0200 Subject: [PATCH 1/2] fix(codegen): keep scope groups out of a later switch case (#11771) The scope-context dominance test compared references against the home list's end inclusively, but list_end is one past the last statement -- the next statement's number. For a switch case that is the first statement of the following case, which shares the binding's scope but skips its let. The binding was grouped, case 2 read the never-allocated scope root (TAG_UNDEFINED) as a pointer, and the #4926 fixture SIGSEGVed. Make the bound exclusive so such bindings keep their entry-initialized per-binding cell. --- .../perry-codegen/src/scope_env/analysis.rs | 12 +++- crates/perry-codegen/src/scope_env/tests.rs | 72 +++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/crates/perry-codegen/src/scope_env/analysis.rs b/crates/perry-codegen/src/scope_env/analysis.rs index f474335f4b..735a8f44c8 100644 --- a/crates/perry-codegen/src/scope_env/analysis.rs +++ b/crates/perry-codegen/src/scope_env/analysis.rs @@ -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}; @@ -47,6 +52,7 @@ pub(super) struct BodyFacts { pub decls: HashMap>, /// Lowest and highest statement number referencing the id. pub refs: HashMap, + /// One past the highest statement number in each list (exclusive). pub list_end: HashMap, pub preallocs: Vec, } @@ -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; } } diff --git a/crates/perry-codegen/src/scope_env/tests.rs b/crates/perry-codegen/src/scope_env/tests.rs index 78c16a25d8..427a7c3d08 100644 --- a/crates/perry-codegen/src/scope_env/tests.rs +++ b/crates/perry-codegen/src/scope_env/tests.rs @@ -186,6 +186,78 @@ fn the_map_follows_the_group_statements() { assert_eq!(map.members(10), &[10, 12]); } +/// `switch (2) { case 1: ; 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) -> Vec { + 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, From 59a5f6e1122597ee5a640da3325af13c33ada131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 3 Oct 2026 00:55:05 +0200 Subject: [PATCH 2/2] changelog: fragment for #11774 --- changelog.d/11774-scope-ctx-switch-case-dominance.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/11774-scope-ctx-switch-case-dominance.md diff --git a/changelog.d/11774-scope-ctx-switch-case-dominance.md b/changelog.d/11774-scope-ctx-switch-case-dominance.md new file mode 100644 index 0000000000..3147e241e9 --- /dev/null +++ b/changelog.d/11774-scope-ctx-switch-case-dominance.md @@ -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.