diff --git a/changelog.d/7087-class-expression-capture-refresh.md b/changelog.d/7087-class-expression-capture-refresh.md new file mode 100644 index 0000000000..9fef424750 --- /dev/null +++ b/changelog.d/7087-class-expression-capture-refresh.md @@ -0,0 +1 @@ +fix(hir): keep capture refreshes scoped to each evaluated class-expression object diff --git a/crates/perry-codegen/src/expr/dispatch.rs b/crates/perry-codegen/src/expr/dispatch.rs index 761621d0cc..04dcf39ba5 100644 --- a/crates/perry-codegen/src/expr/dispatch.rs +++ b/crates/perry-codegen/src/expr/dispatch.rs @@ -493,6 +493,7 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { | Expr::StaticFieldSet { .. } | Expr::RegisterClassParentDynamic { .. } | Expr::RegisterClassCaptures { .. } + | Expr::RefreshClassExprCaptures { .. } | Expr::ClassCaptureValue { .. } | Expr::RegisterClassStaticSymbol { .. } | Expr::RegisterClassComputedMethod { .. } diff --git a/crates/perry-codegen/src/expr/static_field_meta.rs b/crates/perry-codegen/src/expr/static_field_meta.rs index 2711bfc0ce..361925e323 100644 --- a/crates/perry-codegen/src/expr/static_field_meta.rs +++ b/crates/perry-codegen/src/expr/static_field_meta.rs @@ -145,6 +145,45 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } Ok(double_literal(f64::from_bits(0x7FFC_0000_0000_0001))) } + // #6654: refresh one evaluated class expression's OWN capture array. + // The old end-of-body refresh wrote the shared template-name snapshot, + // so `make("b")` could backfill stale slots on the class object returned + // by an earlier `make("a")`. Build the replacement array first, then + // reload the rooted owner local and let the runtime safely no-op when + // this control-flow path never evaluated the class expression. + Expr::RefreshClassExprCaptures { + class_value, + captures, + } => { + let cap_len = captures.len().to_string(); + let mut caps_arr = ctx.block().call(I64, "js_array_alloc", &[(I32, &cap_len)]); + ctx.block().call_void("js_tdz_suppress_begin", &[]); + for capture in captures { + let value = lower_expr(ctx, capture)?; + caps_arr = ctx.block().call( + I64, + "js_array_push_f64", + &[(I64, &caps_arr), (DOUBLE, &value)], + ); + } + ctx.block().call_void("js_tdz_suppress_end", &[]); + let caps_box = nanbox_pointer_inline(ctx.block(), &caps_arr); + // Lower after the allocating array operations so a movable class + // object is reloaded from its compiler-private rooted local. + let owner = lower_expr(ctx, class_value)?; + let key_idx = ctx.strings.intern("__perry_ctor_caps"); + let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); + let key_box = ctx.block().load(DOUBLE, &key_handle_global); + let key_bits = ctx.block().bitcast_double_to_i64(&key_box); + let key_raw = ctx + .block() + .and(I64, &key_bits, crate::nanbox::POINTER_MASK_I64); + ctx.block().call_void( + "js_class_object_refresh_capture_values", + &[(DOUBLE, &owner), (I64, &key_raw), (DOUBLE, &caps_box)], + ); + Ok(owner) + } // Read slot `index` of the class's decl-site capture snapshot — // STATIC method prologue rebinds (no instance to carry the // `__perry_cap_*` fields). @@ -196,11 +235,22 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { (DOUBLE, fb), ], ), - (None, _) => ctx.block().call( - DOUBLE, - "js_class_capture_value", - &[(crate::types::I32, &cid_str), (crate::types::I32, &idx_str)], - ), + (None, _) => { + let receiver = if let Some(this_slot) = ctx.this_stack.last().cloned() { + ctx.block().load(DOUBLE, &this_slot) + } else { + double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + }; + ctx.block().call( + DOUBLE, + "js_class_capture_value_for_receiver", + &[ + (DOUBLE, &receiver), + (crate::types::I32, &cid_str), + (crate::types::I32, &idx_str), + ], + ) + } }); } // Class id unknown in this module: keep the fallback if we have one @@ -395,7 +445,6 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // inlining can't do once the class escapes its defining scope. if !captured_args.is_empty() { let cap_len = captured_args.len().to_string(); - let mut lowered_caps: Vec = Vec::with_capacity(captured_args.len()); let mut caps_arr = ctx.block().call(I64, "js_array_alloc", &[(I32, &cap_len)]); // #6523: these capture loads are Perry-internal materialization // at the class's DEFINITION site, same as the @@ -408,7 +457,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // initialization" while merely DEFINING the class. Suppressed // loads snapshot `undefined`; the #6037 refresh statements // re-register the live values right after each captured - // binding's initializer runs. + // refresh the evaluated object's array right after each + // captured binding's initializer runs. ctx.block().call_void("js_tdz_suppress_begin", &[]); for arg in captured_args { let v = lower_expr(ctx, arg)?; @@ -417,7 +467,6 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "js_array_push_f64", &[(I64, &caps_arr), (DOUBLE, &v)], ); - lowered_caps.push(v); } ctx.block().call_void("js_tdz_suppress_end", &[]); let caps_box = nanbox_pointer_inline(ctx.block(), &caps_arr); @@ -431,39 +480,6 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "js_object_set_field_by_name", &[(I64, &obj), (I64, &key_raw), (DOUBLE, &caps_box)], ); - // #685: ALSO register this evaluation's captures as the - // template's CLASS_CAPTURE_VALUES snapshot. The static blocks - // invoked below run compiled static-method bodies whose - // enclosing-scope reads resolve through - // `js_param_or_class_capture_value` — i.e. the name-keyed - // snapshot — not `__perry_ctor_caps` (that array only feeds - // constructor replay). Same write-right-before-use pattern - // (and same documented per-evaluation overwrite limitation) - // as the shared-template path's `RegisterClassCaptures`. - if template_cid != 0 { - let n = lowered_caps.len(); - let buf = ctx.func.alloca_entry_array(DOUBLE, n); - for (i, v) in lowered_caps.iter().enumerate() { - let slot = - ctx.block() - .gep(DOUBLE, &buf, &[(crate::types::I64, &i.to_string())]); - ctx.block().store(DOUBLE, v, &slot); - } - let ptr_reg = ctx.block().next_reg(); - ctx.block().emit_raw(format!( - "{} = getelementptr [{} x double], ptr {}, i64 0, i64 0", - ptr_reg, n, buf - )); - let len_str = n.to_string(); - ctx.block().call_void( - "js_class_register_capture_values", - &[ - (crate::types::I32, &tcid_str), - (crate::types::PTR, &ptr_reg), - (crate::types::I64, &len_str), - ], - ); - } } let obj_box = nanbox_pointer_inline(ctx.block(), &obj); for (key, init) in symbol_statics { diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 498aeb5e02..8667339996 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -1287,6 +1287,11 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { // Decl-site snapshot of a function-nested class's captured locals — // consumed by the dynamic-construction replay (`new mod.C()`). module.declare_function("js_class_register_capture_values", VOID, &[I32, PTR, I64]); + module.declare_function( + "js_class_object_refresh_capture_values", + VOID, + &[DOUBLE, I64, DOUBLE], + ); // #6052: TDZ-suppression window around the snapshot's capture loads — a // refresh emitted between two `let`/`const` initializers (the #6037 // refresh-after-each-assignment strategy) legally reads a sibling capture @@ -1295,6 +1300,11 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_tdz_suppress_end", VOID, &[]); // Static-method prologue read of one decl-site capture snapshot slot. module.declare_function("js_class_capture_value", DOUBLE, &[I32, I32]); + module.declare_function( + "js_class_capture_value_for_receiver", + DOUBLE, + &[DOUBLE, I32, I32], + ); // #5437: snapshot slot read with a `new`-site appended cap-arg fallback // (used when no decl-site snapshot was registered for the class). module.declare_function("js_class_capture_value_or", DOUBLE, &[I32, I32, DOUBLE]); diff --git a/crates/perry-hir/src/ir/expr.rs b/crates/perry-hir/src/ir/expr.rs index 894cf18b01..e940b9a12e 100644 --- a/crates/perry-hir/src/ir/expr.rs +++ b/crates/perry-hir/src/ir/expr.rs @@ -440,6 +440,17 @@ pub enum Expr { captures: Vec, }, + /// Refresh the capture array carried by one evaluated `ClassExprFresh` + /// object. Unlike `RegisterClassCaptures`, this is keyed by the heap class + /// value itself rather than its shared template name, so a later factory + /// evaluation cannot overwrite an earlier class object's environment. + /// An `undefined` `class_value` is a no-op for paths that did not evaluate + /// the corresponding class expression. + RefreshClassExprCaptures { + class_value: Box, + captures: Vec, + }, + /// Read slot `index` of a class's decl-site capture snapshot /// (`CLASS_CAPTURE_VALUES`, written by `RegisterClassCaptures`). Used by /// STATIC method bodies of function-nested capturing classes — statics diff --git a/crates/perry-hir/src/lower/expr_function.rs b/crates/perry-hir/src/lower/expr_function.rs index 6ab5b466f7..e5e2d7247a 100644 --- a/crates/perry-hir/src/lower/expr_function.rs +++ b/crates/perry-hir/src/lower/expr_function.rs @@ -381,17 +381,13 @@ pub(super) fn lower_arrow(ctx: &mut LoweringContext, arrow: &ast::ArrowExpr) -> vec![Stmt::Return(Some(return_expr))] } }; - // #6604: a capturing class expression in an EXPRESSION-bodied arrow - // (`x => new (class { … })(x)`) records a body-class-expr entry that no - // body twin will drain (the block-bodied arm drains its own inside - // `lower_fn_body_block_stmt`; default-param entries are self-truncated by - // `get_param_default`). Truncate on exit so the entry — whose ids are - // only meaningful in the arrow's own local numbering — never leaks into - // the ENCLOSING body's refresh statements. Nothing is lost: a - // single-expression body has no later statements that could reassign the - // class's captured locals. - ctx.body_class_expr_captures - .truncate(body_class_expr_captures_mark); + // #6654: block-body entries are drained by + // `lower_fn_body_block_stmt`; parameter-default entries (and entries from + // an expression body) remain in this suffix and must be applied only after + // the default/destructuring prologue has been assembled below. + let class_expr_entries = ctx + .body_class_expr_captures + .split_off(body_class_expr_captures_mark); ctx.current_strict = outer_strict; // Prepend destructuring statements to body @@ -419,6 +415,7 @@ pub(super) fn lower_arrow(ctx: &mut LoweringContext, arrow: &ast::ArrowExpr) -> param_eval_var_stmts.append(&mut body); body = param_eval_var_stmts; } + apply_class_expr_capture_refreshes(&mut body, class_expr_entries); ctx.exit_strict_mode(); ctx.exit_scope(scope_mark); @@ -596,10 +593,9 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul let scope_mark = ctx.enter_scope(); // #6604: capturing class EXPRESSIONS lowered in THIS function register // from here for the end-of-body refresh (twin of - // `lower_fn_body_block_stmt`); the mark sits at scope entry so nothing - // recorded for this function can leak into the enclosing body. - // (Default-param entries never reach the drain — `get_param_default` - // self-truncates.) + // `lower_fn_body_block_stmt`); the mark sits at scope entry so parameter + // defaults are included and nothing recorded for this function can leak + // into the enclosing body. let body_class_expr_captures_mark = ctx.body_class_expr_captures.len(); // A plain function has its own `arguments` object, so a direct `eval` // inside its body may reference `arguments` even when the function sits @@ -765,6 +761,7 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul // #4950: undefined-initialised `Stmt::Let`s for `var`s found nested in // compound statements — prepended to the lowered body below. let mut nested_var_prologue: Vec = Vec::new(); + let mut class_expr_entries = Vec::new(); if let Some(ref block) = fn_expr.function.body { // Issue #838 followup (b): pre-register top-level `var` decls in // this function body BEFORE lowering any statement. dayjs's @@ -1246,23 +1243,14 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul // #6604: capturing class EXPRESSIONS lowered directly in this body — // the semver/esbuild `__commonJS` wrapper shape `var Comparator = // class _Comparator { … }; …; var parseOptions = require_…()` — join - // the same refresh machinery as class declarations, so the snapshot - // tracks captured vars assigned AFTER the class. Recorded by - // `lower_class_expr` under the RESOLVED registration name; see the - // block-body twin (`lower_fn_body_block_stmt`) for why no - // `append_new_args_stmt` pass runs for expressions. - for (cname, ids) in ctx + // the same assignment/return placement as class declarations, while + // #6654 targets the evaluated heap class object's capture array via a + // compiler-private owner local. See the block-body twin + // (`lower_fn_body_block_stmt`) for why no `append_new_args_stmt` pass + // runs for expressions. + class_expr_entries = ctx .body_class_expr_captures - .split_off(body_class_expr_captures_mark) - { - let captures: Vec = ids.iter().map(|id| Expr::LocalGet(*id)).collect(); - let re_reg = Stmt::Expr(Expr::RegisterClassCaptures { - class_name: cname, - captures, - }); - re_reg_capsets.push((re_reg.clone(), ids.iter().copied().collect())); - re_regs.push(re_reg); - } + .split_off(body_class_expr_captures_mark); if !re_regs.is_empty() { // Audit P0-B twin of the block-body path: refresh after every // same-body assignment to a captured local so mid-body constructs @@ -1315,6 +1303,7 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul new_body.append(&mut body); body = new_body; } + apply_class_expr_capture_refreshes(&mut body, class_expr_entries); ctx.exit_strict_mode(); ctx.exit_scope(scope_mark); @@ -1500,6 +1489,175 @@ fn compute_closure_captures( (captures, mutable_captures) } +/// Materialize and wire the per-evaluation refresh owners recorded by +/// `lower_class_expr` into one executable body/module-init region. +/// +/// Each owner starts as `undefined`, is assigned only when its class +/// expression actually evaluates, and is refreshed after assignments to any +/// captured binding plus immediately before returns. Codegen treats an +/// undefined owner as a no-op, so conditional/skipped evaluations cannot +/// mutate an object retained by an earlier factory call. +pub(crate) fn apply_class_expr_capture_refreshes( + body: &mut Vec, + entries: Vec<(LocalId, Vec)>, +) { + if entries.is_empty() { + return; + } + + let mut owner_lets = Vec::new(); + let mut refreshes = Vec::new(); + let mut refresh_capsets = Vec::new(); + let mut seen_owners = std::collections::HashSet::new(); + for (owner, ids) in entries { + if seen_owners.insert(owner) { + owner_lets.push(Stmt::Let { + id: owner, + name: format!("__perry_class_expr_capture_owner_{owner}"), + ty: crate::types::Type::Any, + mutable: true, + init: Some(Expr::Undefined), + }); + } + let refresh = Stmt::Expr(Expr::RefreshClassExprCaptures { + class_value: Box::new(Expr::LocalGet(owner)), + captures: ids.iter().map(|id| Expr::LocalGet(*id)).collect(), + }); + refresh_capsets.push((refresh.clone(), ids.iter().copied().collect())); + refreshes.push(refresh); + } + + insert_class_capture_refresh_inside_expressions(body, &refresh_capsets); + insert_class_capture_refresh_after_assignments(body, &refresh_capsets); + insert_class_capture_refresh_before_returns(body, &refreshes); + owner_lets.append(body); + *body = owner_lets; +} + +/// Insert per-object refreshes immediately after assignment expressions, not +/// merely after their containing statement. This preserves evaluation order +/// for expression-bodied arrows and comma/conditional expressions: +/// +/// `x => (C = class { m(){ return x } }, x = 2, C)` +/// +/// A statement-level refresh would land after the `return` (unreachable), and +/// a return-prologue refresh runs before the class is evaluated. Rewriting the +/// watched `LocalSet` to `(x = value, refresh(owner), x)` keeps the assignment +/// expression's result while making later subexpressions observe the refreshed +/// class environment. Closure bodies are intentionally not descended: each +/// closure lowering region owns its own LocalId space and refresh entries. +fn insert_class_capture_refresh_inside_expressions( + stmts: &mut [Stmt], + regs: &[(Stmt, std::collections::HashSet)], +) { + fn visit_expr(expr: &mut Expr, regs: &[(Stmt, std::collections::HashSet)]) { + if matches!(expr, Expr::Closure { .. }) { + return; + } + crate::walker::walk_expr_children_mut(expr, &mut |child| visit_expr(child, regs)); + + let assigned_id = match expr { + Expr::LocalSet(id, _) => *id, + _ => return, + }; + let mut refresh_exprs = Vec::new(); + for (refresh, capset) in regs { + if !capset.contains(&assigned_id) { + continue; + } + if let Stmt::Expr(refresh_expr) = refresh { + refresh_exprs.push(refresh_expr.clone()); + } + } + if refresh_exprs.is_empty() { + return; + } + + let assignment = std::mem::replace(expr, Expr::Undefined); + let mut sequence = Vec::with_capacity(refresh_exprs.len() + 2); + sequence.push(assignment); + sequence.append(&mut refresh_exprs); + // `LocalSet` evaluates to the assigned value. Reloading the local after + // side-effect-free capture materialization preserves that result. + sequence.push(Expr::LocalGet(assigned_id)); + *expr = Expr::Sequence(sequence); + } + + fn visit_stmt(stmt: &mut Stmt, regs: &[(Stmt, std::collections::HashSet)]) { + match stmt { + Stmt::Let { + init: Some(expr), .. + } + | Stmt::Expr(expr) + | Stmt::Return(Some(expr)) + | Stmt::Throw(expr) => visit_expr(expr, regs), + Stmt::If { + condition, + then_branch, + else_branch, + } => { + visit_expr(condition, regs); + insert_class_capture_refresh_inside_expressions(then_branch, regs); + if let Some(branch) = else_branch { + insert_class_capture_refresh_inside_expressions(branch, regs); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + visit_expr(condition, regs); + insert_class_capture_refresh_inside_expressions(body, regs); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(init) = init { + visit_stmt(init, regs); + } + if let Some(condition) = condition { + visit_expr(condition, regs); + } + if let Some(update) = update { + visit_expr(update, regs); + } + insert_class_capture_refresh_inside_expressions(body, regs); + } + Stmt::Labeled { body, .. } => visit_stmt(body, regs), + Stmt::Try { + body, + catch, + finally, + } => { + insert_class_capture_refresh_inside_expressions(body, regs); + if let Some(catch) = catch { + insert_class_capture_refresh_inside_expressions(&mut catch.body, regs); + } + if let Some(finally) = finally { + insert_class_capture_refresh_inside_expressions(finally, regs); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + visit_expr(discriminant, regs); + for case in cases { + if let Some(test) = &mut case.test { + visit_expr(test, regs); + } + insert_class_capture_refresh_inside_expressions(&mut case.body, regs); + } + } + _ => {} + } + } + + for stmt in stmts { + visit_stmt(stmt, regs); + } +} + /// Insert a class-capture refresh immediately AFTER every statement that /// assigns one of the class's captured locals (2026-07-02 audit capture /// P0-B). The decl-site snapshot is AUTHORITATIVE at construct time (the diff --git a/crates/perry-hir/src/lower/expr_object.rs b/crates/perry-hir/src/lower/expr_object.rs index a879a7bf6f..2875b01186 100644 --- a/crates/perry-hir/src/lower/expr_object.rs +++ b/crates/perry-hir/src/lower/expr_object.rs @@ -203,6 +203,7 @@ fn lower_method_prop( .collect(); let scope_mark = ctx.enter_scope(); + let class_expr_capture_mark = ctx.body_class_expr_captures.len(); let saved_in_nonarrow_fn = ctx.in_nonarrow_fn; ctx.in_nonarrow_fn = true; // Object-literal methods are NOT implicitly strict (unlike class bodies): @@ -343,6 +344,10 @@ fn lower_method_prop( new_body.append(&mut body); body = new_body; } + let class_expr_entries = ctx + .body_class_expr_captures + .split_off(class_expr_capture_mark); + crate::lower::expr_function::apply_class_expr_capture_refreshes(&mut body, class_expr_entries); ctx.exit_strict_mode(); ctx.exit_scope(scope_mark); ctx.in_nonarrow_fn = saved_in_nonarrow_fn; @@ -501,6 +506,7 @@ fn lower_accessor_prop( .collect(); let scope_mark = ctx.enter_scope(); + let class_expr_capture_mark = ctx.body_class_expr_captures.len(); let saved_in_nonarrow_fn = ctx.in_nonarrow_fn; ctx.in_nonarrow_fn = true; // Accessors in object literals inherit strictness (see lower_method_prop). @@ -539,11 +545,21 @@ fn lower_accessor_prop( } } - let body = if let Some(block) = body { + let mut body = if let Some(block) = body { lower_fn_body_block_stmt(ctx, block)? } else { Vec::new() }; + let default_stmts = crate::lower_decl::build_default_param_stmts(¶ms); + if !default_stmts.is_empty() { + let mut new_body = default_stmts; + new_body.append(&mut body); + body = new_body; + } + let class_expr_entries = ctx + .body_class_expr_captures + .split_off(class_expr_capture_mark); + crate::lower::expr_function::apply_class_expr_capture_refreshes(&mut body, class_expr_entries); ctx.exit_strict_mode(); ctx.exit_scope(scope_mark); ctx.in_nonarrow_fn = saved_in_nonarrow_fn; diff --git a/crates/perry-hir/src/lower/lower_expr/arm_class.rs b/crates/perry-hir/src/lower/lower_expr/arm_class.rs index 34814b9654..54e96687af 100644 --- a/crates/perry-hir/src/lower/lower_expr/arm_class.rs +++ b/crates/perry-hir/src/lower/lower_expr/arm_class.rs @@ -180,23 +180,39 @@ pub(crate) fn lower_class_expr( // expressions inside a function body (factories like effect's // `make()`), which produce a distinct class object per call. let at_module_top = ctx.scope_depth == 0 && ctx.inside_block_scope == 0; - // #6604: register this capturing class EXPRESSION with the enclosing + // #6604/#6654: register this capturing class EXPRESSION with the enclosing // body's end-of-body capture-refresh machinery (#6037/#6052), which // previously scanned class DECLARATION statements only. Without the // refresh, a captured var assigned AFTER the class expression (semver's // `var Comparator = class _Comparator { … }; …; var parseOptions = // require_parse_options()`) stays `undefined` in the decl-site snapshot, // and dynamic construction of the escaped class value replays that stale - // snapshot. Recording the RESOLVED registration name here (post - // rename/dedup) sidesteps re-deriving it from the AST at body end. Module - // top is skipped — module-level ids are stripped from capture lists by - // `filter_module_level_captures`, so there is nothing to refresh. - if !at_module_top && !captured_args.is_empty() { - if let Some(ids) = ctx.lookup_class_captures(&synthetic_name) { - ctx.body_class_expr_captures - .push((synthetic_name.clone(), ids.to_vec())); + // snapshot. #6654 keeps the refresh target in a compiler-private local: + // a template-name-keyed snapshot lets a later `make("b")` overwrite the + // captures used by the class object returned from `make("a")`. The local + // is initialized at the owning body/module entry and assigned the fresh + // class object at this exact evaluation site; guarded refreshes therefore + // update only the object that was actually evaluated in this invocation. + // Module top is skipped — module-level ids are stripped from capture lists + // by `filter_module_level_captures`, so there is nothing to refresh. + let capture_owner = if !at_module_top && !captured_args.is_empty() { + let ids = ctx + .lookup_class_captures(&synthetic_name) + .map(<[_]>::to_vec) + .unwrap_or_default(); + if ids.is_empty() { + None + } else { + let owner = ctx.define_local( + format!("__perry_class_expr_capture_owner_{synthetic_name}"), + crate::types::Type::Any, + ); + ctx.body_class_expr_captures.push((owner, ids)); + Some(owner) } - } + } else { + None + }; if !at_module_top && (!named_statics.is_empty() || !static_symbol_registrations.is_empty() @@ -251,6 +267,14 @@ pub(crate) fn lower_class_expr( }); } seq.extend(computed_member_registrations); + let fresh_expr = if let Some(owner) = capture_owner { + Expr::Sequence(vec![ + Expr::LocalSet(owner, Box::new(fresh_expr)), + Expr::LocalGet(owner), + ]) + } else { + fresh_expr + }; if seq.is_empty() { return Ok(fresh_expr); } diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index c1c903484c..1cfacfce5f 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -931,6 +931,17 @@ pub fn lower_module_full( } } + // #6654: capturing class expressions inside module-level blocks have no + // function-body owner to drain their refresh entries. Apply every entry + // left after function lowering to module init itself; the compiler-private + // owner lets make skipped control-flow paths harmless, while assignment + // tracking keeps escaped classes tied to their own evaluated object. + let module_class_expr_entries = std::mem::take(&mut ctx.body_class_expr_captures); + crate::lower::expr_function::apply_class_expr_capture_refreshes( + &mut module.init, + module_class_expr_entries, + ); + // #5579: record whether the source references `globalThis`, gating the // codegen reflection of top-level `function` declarations onto the global // object (see `Module::references_global_this`). The module source is diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index e9a68f7639..63c28f7e92 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -794,8 +794,9 @@ pub struct LoweringContext { /// here so the `Expr::New { class_name }` lowering can append /// `LocalGet(id)` for each captured id at every construction site. pub(crate) class_captures: Vec<(String, Vec)>, - /// #6604: capturing class EXPRESSIONS lowered while the CURRENT function - /// body is being lowered — `(registration_name, captured_outer_ids)`, + /// #6604/#6654: capturing class EXPRESSIONS lowered while the CURRENT + /// function body is being lowered — + /// `(per_evaluation_owner_local, captured_outer_ids)`, /// pushed by `lower_class_expr` (skipped at module top, where /// `filter_module_level_captures` already strips module-level ids). The /// #6037/#6052 end-of-body capture-refresh machinery previously scanned @@ -810,7 +811,7 @@ pub struct LoweringContext { /// every other body-lowering path must truncate back to its entry mark so /// entries (whose ids are only meaningful in THEIR OWN function scope) /// never leak into an enclosing body's refresh statements. - pub(crate) body_class_expr_captures: Vec<(String, Vec)>, + pub(crate) body_class_expr_captures: Vec<(LocalId, Vec)>, /// Issue #740: `let_name → class_name` for `let/const/var = ` /// initializers. Lets `Expr::New { class_name }` (where `class_name` is /// the source-level identifier of an alias binding) resolve to the diff --git a/crates/perry-hir/src/lower_decl/block.rs b/crates/perry-hir/src/lower_decl/block.rs index c8bf0e8ae2..3727e17af6 100644 --- a/crates/perry-hir/src/lower_decl/block.rs +++ b/crates/perry-hir/src/lower_decl/block.rs @@ -1298,26 +1298,16 @@ pub fn lower_fn_body_block_stmt( // (`var Comparator = class _Comparator { … }`, argument-position // `register(class { … })`, …) need the same assignment-tracking // refresh as class declarations: semver assigns the captured - // `parseOptions`/`debug` vars AFTER the class, so the snapshot (and - // the per-evaluation `__perry_ctor_caps` array, whose stale-undefined - // slots the runtime construct path now backfills from this snapshot) - // must be re-registered with the live values. Entries were recorded - // by `lower_class_expr` under the RESOLVED registration name; no - // `append_new_args_stmt` pass — a class expression's construct sites - // are either static (binding-name `new C()`, live locals appended at - // the site) or dynamic (replayed through the snapshot). - for (cname, ids) in ctx + // `parseOptions`/`debug` vars AFTER the class, so the evaluated heap + // class object's `__perry_ctor_caps` array must be refreshed with the + // live values. #6654 records a compiler-private owner local rather + // than the shared template name, preserving factory-call isolation. + // No `append_new_args_stmt` pass — a class expression's construct + // sites are either static (binding-name `new C()`, live locals + // appended at the site) or dynamic (replayed from the object's array). + let class_expr_entries = ctx .body_class_expr_captures - .split_off(body_class_expr_captures_mark) - { - let captures: Vec = ids.iter().map(|id| Expr::LocalGet(*id)).collect(); - let re_reg = Stmt::Expr(Expr::RegisterClassCaptures { - class_name: cname, - captures, - }); - re_reg_capsets.push((re_reg.clone(), ids.iter().copied().collect())); - re_regs.push(re_reg); - } + .split_off(body_class_expr_captures_mark); if !re_regs.is_empty() { // Audit P0-B: the decl-site snapshot is authoritative at // construct time, so keep it TRACKING same-body assignments — @@ -1332,6 +1322,10 @@ pub fn lower_fn_body_block_stmt( &mut body, &re_regs, ); } + crate::lower::expr_function::apply_class_expr_capture_refreshes( + &mut body, + class_expr_entries, + ); } ctx.forward_class_names = saved_forward_class_names; ctx.forward_class_decl_depth = saved_forward_class_decl_depth; diff --git a/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs b/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs index 3c1fe5abab..75a5f607b2 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs @@ -62,6 +62,7 @@ pub(super) fn lower_nested_fn_decl( }; let scope_mark = ctx.enter_scope(); + let class_expr_capture_mark = ctx.body_class_expr_captures.len(); let saved_in_nonarrow_fn = ctx.in_nonarrow_fn; ctx.in_nonarrow_fn = true; @@ -194,6 +195,10 @@ pub(super) fn lower_nested_fn_decl( new_body.append(&mut body); body = new_body; } + let class_expr_entries = ctx + .body_class_expr_captures + .split_off(class_expr_capture_mark); + crate::lower::expr_function::apply_class_expr_capture_refreshes(&mut body, class_expr_entries); ctx.exit_scope(scope_mark); ctx.in_nonarrow_fn = saved_in_nonarrow_fn; diff --git a/crates/perry-hir/src/lower_decl/class_members.rs b/crates/perry-hir/src/lower_decl/class_members.rs index 769a3e9c0a..b6ec139b2f 100644 --- a/crates/perry-hir/src/lower_decl/class_members.rs +++ b/crates/perry-hir/src/lower_decl/class_members.rs @@ -15,6 +15,7 @@ pub fn lower_constructor( ctor: &ast::Constructor, ) -> Result { let scope_mark = ctx.enter_scope(); + let class_expr_capture_mark = ctx.body_class_expr_captures.len(); let saved_in_nonarrow_fn = ctx.in_nonarrow_fn; ctx.in_nonarrow_fn = true; ctx.enter_strict_mode(true); @@ -209,6 +210,10 @@ pub fn lower_constructor( new_body.append(&mut body); body = new_body; } + let class_expr_entries = ctx + .body_class_expr_captures + .split_off(class_expr_capture_mark); + crate::lower::expr_function::apply_class_expr_capture_refreshes(&mut body, class_expr_entries); ctx.exit_strict_mode(); ctx.exit_scope(scope_mark); @@ -472,6 +477,7 @@ pub fn lower_class_method_with_name( ctx.enter_type_param_scope(&type_params); let scope_mark = ctx.enter_scope(); + let class_expr_capture_mark = ctx.body_class_expr_captures.len(); let saved_in_nonarrow_fn = ctx.in_nonarrow_fn; ctx.in_nonarrow_fn = true; ctx.enter_strict_mode(true); @@ -619,6 +625,10 @@ pub fn lower_class_method_with_name( new_body.extend(body); body = new_body; } + let class_expr_entries = ctx + .body_class_expr_captures + .split_off(class_expr_capture_mark); + crate::lower::expr_function::apply_class_expr_capture_refreshes(&mut body, class_expr_entries); // Phase 4 (expansion): body-based return-type inference for unannotated // methods. Same pattern as `lower_fn_decl`: skip when annotation is @@ -797,6 +807,7 @@ pub fn lower_setter_method_with_name( name: String, ) -> Result { let scope_mark = ctx.enter_scope(); + let class_expr_capture_mark = ctx.body_class_expr_captures.len(); let saved_in_nonarrow_fn = ctx.in_nonarrow_fn; ctx.in_nonarrow_fn = true; ctx.enter_strict_mode(true); @@ -878,6 +889,10 @@ pub fn lower_setter_method_with_name( new_body.append(&mut body); body = new_body; } + let class_expr_entries = ctx + .body_class_expr_captures + .split_off(class_expr_capture_mark); + crate::lower::expr_function::apply_class_expr_capture_refreshes(&mut body, class_expr_entries); ctx.exit_strict_mode(); ctx.exit_scope(scope_mark); diff --git a/crates/perry-hir/src/lower_decl/fn_decl.rs b/crates/perry-hir/src/lower_decl/fn_decl.rs index 5a131ead13..8ddc8b07b2 100644 --- a/crates/perry-hir/src/lower_decl/fn_decl.rs +++ b/crates/perry-hir/src/lower_decl/fn_decl.rs @@ -68,6 +68,7 @@ pub fn lower_fn_decl(ctx: &mut LoweringContext, fn_decl: &ast::FnDecl) -> Result ctx.enter_type_param_scope(&type_params); let scope_mark = ctx.enter_scope(); + let class_expr_capture_mark = ctx.body_class_expr_captures.len(); let saved_in_nonarrow_fn = ctx.in_nonarrow_fn; ctx.in_nonarrow_fn = true; @@ -344,6 +345,13 @@ pub fn lower_fn_decl(ctx: &mut LoweringContext, fn_decl: &ast::FnDecl) -> Result new_body.append(&mut body); body = new_body; } + let param_class_expr_entries = ctx + .body_class_expr_captures + .split_off(class_expr_capture_mark); + crate::lower::expr_function::apply_class_expr_capture_refreshes( + &mut body, + param_class_expr_entries, + ); // After body lowering, check if any return statement returns a native instance. // This handles patterns like: function initDb() { const d = new Database(...); return d; } diff --git a/crates/perry-hir/src/lower_decl/private_members.rs b/crates/perry-hir/src/lower_decl/private_members.rs index c556e08b58..bce716e80b 100644 --- a/crates/perry-hir/src/lower_decl/private_members.rs +++ b/crates/perry-hir/src/lower_decl/private_members.rs @@ -85,6 +85,7 @@ pub fn lower_private_method( ctx.enter_type_param_scope(&type_params); let scope_mark = ctx.enter_scope(); + let class_expr_capture_mark = ctx.body_class_expr_captures.len(); let saved_in_nonarrow_fn = ctx.in_nonarrow_fn; ctx.in_nonarrow_fn = true; ctx.enter_strict_mode(true); @@ -194,6 +195,10 @@ pub fn lower_private_method( new_body.extend(body); body = new_body; } + let class_expr_entries = ctx + .body_class_expr_captures + .split_off(class_expr_capture_mark); + crate::lower::expr_function::apply_class_expr_capture_refreshes(&mut body, class_expr_entries); ctx.exit_strict_mode(); ctx.exit_scope(scope_mark); diff --git a/crates/perry-hir/src/lower_patterns.rs b/crates/perry-hir/src/lower_patterns.rs index 0055a4fe06..217044e333 100644 --- a/crates/perry-hir/src/lower_patterns.rs +++ b/crates/perry-hir/src/lower_patterns.rs @@ -1454,20 +1454,13 @@ pub(crate) fn get_param_default(ctx: &mut LoweringContext, pat: &ast::Pat) -> Re } } ast::Pat::Assign(assign) => { - // #6604: a capturing class EXPRESSION used as a default value - // (`function f(C = class { … }) {}`) must NOT register with the - // enclosing body's end-of-body capture-refresh machinery: param - // defaults are lowered BEFORE the callee's own body twin takes - // its list mark (fn-decl / ctor / method param sites), so the - // entry would be drained by the WRONG (enclosing) body and its - // ids interpreted in the wrong function's local numbering. - // Truncate whatever this default expression recorded — the - // default is re-evaluated at every call anyway, so its - // evaluation-time snapshot is per-call fresh. - let mark = ctx.body_class_expr_captures.len(); - let default_expr = lower_expr(ctx, &assign.right)?; - ctx.body_class_expr_captures.truncate(mark); - Ok(Some(default_expr)) + // #6654: retain class-expression refresh entries created by a + // default. Per-call evaluation gives the class a fresh object, but + // later parameter defaults and the function body can still mutate + // bindings it captured (`f(x, C = class{ get(){return x} }) { + // x = 2 }`). Each function lowering region now owns and drains its + // suffix after assembling the complete parameter/body prologue. + Ok(Some(lower_expr(ctx, &assign.right)?)) } _ => Ok(None), } diff --git a/crates/perry-hir/src/stable_hash/expr.rs b/crates/perry-hir/src/stable_hash/expr.rs index 5cddce2581..fc35fd7664 100644 --- a/crates/perry-hir/src/stable_hash/expr.rs +++ b/crates/perry-hir/src/stable_hash/expr.rs @@ -643,6 +643,7 @@ impl SH for Expr { Expr::TemplateRaw(e) => { tag(h, 446); e.as_ref().hash(h); } Expr::RegisterClassParentDynamic { class_name, parent_expr, } => { tag(h, 447); class_name.hash(h); parent_expr.as_ref().hash(h); } Expr::RegisterClassCaptures { class_name, captures } => { tag(h, 12241); class_name.hash(h); for c in captures { c.hash(h); } } + Expr::RefreshClassExprCaptures { class_value, captures } => { tag(h, 12243); class_value.as_ref().hash(h); for c in captures { c.hash(h); } } Expr::ClassCaptureValue { class_name, index, fallback, prefer_fallback } => { tag(h, 12242); class_name.hash(h); index.hash(h); fallback.hash(h); prefer_fallback.hash(h); } Expr::RegisterClassStaticSymbol { class_name, key_expr, value_expr, } => { tag(h, 12025); class_name.hash(h); key_expr.as_ref().hash(h); value_expr.as_ref().hash(h); } Expr::RegisterClassComputedMethod { class_name, key_expr, method_name, is_static, param_count, has_rest } => { tag(h, 12233); class_name.hash(h); key_expr.as_ref().hash(h); method_name.hash(h); is_static.hash(h); param_count.hash(h); has_rest.hash(h); } diff --git a/crates/perry-hir/src/walker/expr_mut.rs b/crates/perry-hir/src/walker/expr_mut.rs index 77d9335b8d..821eb2d7ff 100644 --- a/crates/perry-hir/src/walker/expr_mut.rs +++ b/crates/perry-hir/src/walker/expr_mut.rs @@ -585,6 +585,15 @@ where f(c); } } + Expr::RefreshClassExprCaptures { + class_value, + captures, + } => { + f(class_value); + for c in captures { + f(c); + } + } Expr::ClassCaptureValue { fallback, .. } => { if let Some(fb) = fallback { f(fb); diff --git a/crates/perry-hir/src/walker/expr_ref.rs b/crates/perry-hir/src/walker/expr_ref.rs index 7bad53d64e..59a0248950 100644 --- a/crates/perry-hir/src/walker/expr_ref.rs +++ b/crates/perry-hir/src/walker/expr_ref.rs @@ -586,6 +586,15 @@ where f(c); } } + Expr::RefreshClassExprCaptures { + class_value, + captures, + } => { + f(class_value); + for c in captures { + f(c); + } + } Expr::ClassCaptureValue { fallback, .. } => { if let Some(fb) = fallback { f(fb); diff --git a/crates/perry-runtime/src/object/class_constructors.rs b/crates/perry-runtime/src/object/class_constructors.rs index 556840a331..036f6566df 100644 --- a/crates/perry-runtime/src/object/class_constructors.rs +++ b/crates/perry-runtime/src/object/class_constructors.rs @@ -12,6 +12,62 @@ use std::sync::RwLock; use super::class_registry::call_vtable_method; use super::ObjectHeader; +/// Replace the capture array carried by one heap class-expression value. +/// Invalid/undefined owners are intentional no-ops: the lowering emits guarded +/// refresh sites along every assignment path, including paths that skipped the +/// corresponding class expression. +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_CLASS_OBJECT_REFRESH_CAPTURE_VALUES: extern "C" fn(f64, i64, f64) = + js_class_object_refresh_capture_values; + +#[no_mangle] +pub extern "C" fn js_class_object_refresh_capture_values( + class_value: f64, + key: i64, + captures: f64, +) { + if key == 0 || !super::class_registry::is_class_object_value(class_value) { + return; + } + let object = crate::value::JSValue::from_bits(class_value.to_bits()) + .as_pointer::() as *mut ObjectHeader; + if object.is_null() { + return; + } + super::js_object_set_field_by_name(object, key as *const crate::StringHeader, captures); +} + +/// Read a static method capture from the actual receiver when it is a fresh +/// class-expression object, falling back to the declaration/template snapshot +/// for ordinary class refs. The per-object path preserves explicit +/// `undefined` slots and therefore never consults a later evaluation's +/// name-keyed state. +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_CLASS_CAPTURE_VALUE_FOR_RECEIVER: extern "C" fn(f64, u32, u32) -> f64 = + js_class_capture_value_for_receiver; + +#[no_mangle] +pub extern "C" fn js_class_capture_value_for_receiver( + receiver: f64, + class_id: u32, + index: u32, +) -> f64 { + if super::class_registry::is_class_object_value(receiver) { + let caps_value = + super::js_object_get_own_field_or_undef(receiver, b"__perry_ctor_caps".as_ptr(), 17); + let caps = crate::value::JSValue::from_bits(caps_value.to_bits()); + if caps.is_pointer() { + let array = caps.as_pointer::(); + if !array.is_null() && index < crate::array::js_array_length(array) { + return crate::array::js_array_get_f64(array, index); + } + } + } + js_class_capture_value(class_id, index) +} + /// #1787: per-template constructor function pointers, keyed by the /// compile-time class_id. The value is `(fn_ptr, total_param_count)`: /// `fn_ptr` is the standalone `___constructor` LLVM symbol diff --git a/test-parity/node-suite/object/class-expr-capture-refresh-edge.js b/test-parity/node-suite/object/class-expr-capture-refresh-edge.js new file mode 100644 index 0000000000..f2c370654d --- /dev/null +++ b/test-parity/node-suite/object/class-expr-capture-refresh-edge.js @@ -0,0 +1,78 @@ +// #6654: capture refreshes must stay attached to the evaluated class object +// across parameter defaults, repeated factory evaluation, and top-level blocks. + +function defaultInBlock( + x, + C = class { + get() { + return x; + } + }, +) { + x = "body"; + return C; +} +console.log("param-block:", new (defaultInBlock("initial"))().get()); + +const defaultInArrow = ( + x, + C = class { + get() { + return x; + } + }, + update = (x = "later-param"), +) => C; +console.log("param-arrow:", new (defaultInArrow("initial"))().get()); + +const updateInArrowExpression = (x, C) => ( + (C = class { + get() { + return x; + } + }), + (x = "expression"), + C +); +console.log( + "expression-arrow:", + new (updateInArrowExpression("initial"))().get(), +); + +function makeAssignedAfter(tag) { + const C = class { + get() { + return value; + } + }; + const value = tag; + return C; +} +const A = makeAssignedAfter("a"); +const B = makeAssignedAfter("b"); +console.log("multi-eval:", new A().get(), new B().get()); + +function makeStaticAssignedAfter(tag) { + const C = class { + static get() { + return value; + } + }; + const value = tag; + return C; +} +const StaticA = makeStaticAssignedAfter("sa"); +const StaticB = makeStaticAssignedAfter("sb"); +console.log("multi-static:", StaticA.get(), StaticB.get()); + +const escaped = {}; +{ + let value = "before"; + escaped.K = class { + get() { + return value; + } + }; + value = "after"; +} +console.log("top-block:", new escaped.K().get()); diff --git a/tests/test_class_expr_capture_refresh_6654.sh b/tests/test_class_expr_capture_refresh_6654.sh new file mode 100755 index 0000000000..a88d457c15 --- /dev/null +++ b/tests/test_class_expr_capture_refresh_6654.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Regression (#6654): capture refreshes for class expressions must belong to +# the evaluated heap class object, including parameter defaults and module +# blocks. A template-name-keyed snapshot lets a later factory call overwrite an +# earlier class's environment; dropping/stranding refresh entries leaves later +# parameter/body assignments invisible. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$SCRIPT_DIR/.." +FIXTURE="$ROOT/test-parity/node-suite/object/class-expr-capture-refresh-edge.js" +PERRY="${PERRY:-$ROOT/target/release/perry}" +[ ! -f "$PERRY" ] && PERRY="$ROOT/target/debug/perry" +if [ ! -f "$PERRY" ]; then + echo "SKIP: perry binary not found (build with cargo build --release)" + exit 0 +fi +if ! command -v node >/dev/null 2>&1; then + echo "SKIP: node not available" + exit 0 +fi +if ! command -v cc >/dev/null 2>&1; then + echo "SKIP: cc not available" + exit 0 +fi + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +NODE_OUTPUT=$(node "$FIXTURE") +COMPILE_OUTPUT=$( + PERRY_NO_AUTO_OPTIMIZE=1 "$PERRY" compile "$FIXTURE" \ + -o "$TMPDIR/test_bin" --no-cache 2>&1 +) || { + echo "FAIL: compile error" + echo "$COMPILE_OUTPUT" | tail -20 + exit 1 +} +PERRY_OUTPUT=$("$TMPDIR/test_bin") + +if [ "$PERRY_OUTPUT" = "$NODE_OUTPUT" ]; then + echo "PASS" + exit 0 +fi + +echo "FAIL: class-expression capture refresh diverged from Node" +echo "Node:" +echo "$NODE_OUTPUT" +echo "Perry:" +echo "$PERRY_OUTPUT" +exit 1