diff --git a/crates/perry-codegen/src/expr/logical_collections.rs b/crates/perry-codegen/src/expr/logical_collections.rs index c740cdac01..c7f66d6609 100644 --- a/crates/perry-codegen/src/expr/logical_collections.rs +++ b/crates/perry-codegen/src/expr/logical_collections.rs @@ -884,6 +884,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { object, } => { let obj = lower_expr(ctx, object)?; + // The rooting window between these two operands is empty: + // lowering `this` only reads the current binding and cannot GC. + let brand_owner = lower_expr(ctx, &Expr::This)?; let class_id = ctx.class_ids.get(class_name).copied().unwrap_or(0); let key_label = emit_string_literal_global(ctx, field_name); Ok(ctx.block().call( @@ -891,6 +894,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "js_private_brand_check", &[ (DOUBLE, &obj), + (DOUBLE, &brand_owner), (I32, &class_id.to_string()), (PTR, &key_label), (I32, &field_name.len().to_string()), @@ -909,6 +913,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // unchanged (or throw TypeError). The enclosing PropertyGet / // PropertySet / method-call lowering then operates on the result. let obj = lower_expr(ctx, object)?; + // The rooting window between these two operands is empty: + // lowering `this` only reads the current binding and cannot GC. + let brand_owner = lower_expr(ctx, &Expr::This)?; // Prefer the declaring class's unique HIR id carried on the node. // Resolving `class_name` through `class_ids` is ambiguous: that map // is keyed by name (last-writer-wins), so a minified bundle that @@ -926,6 +933,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "js_private_guard", &[ (DOUBLE, &obj), + (DOUBLE, &brand_owner), (I32, &class_id.to_string()), (PTR, &key_label), (I32, &field_name.len().to_string()), diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index b37f2e1bdd..c7889a04d8 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -413,11 +413,15 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_map_from_iterable", I64, &[DOUBLE]); module.declare_function("js_object_has_property", DOUBLE, &[DOUBLE, DOUBLE]); module.declare_function("js_in_operator", DOUBLE, &[DOUBLE, DOUBLE]); - module.declare_function("js_private_brand_check", DOUBLE, &[DOUBLE, I32, PTR, I32]); + module.declare_function( + "js_private_brand_check", + DOUBLE, + &[DOUBLE, DOUBLE, I32, PTR, I32], + ); module.declare_function( "js_private_guard", DOUBLE, - &[DOUBLE, I32, PTR, I32, I32, I32], + &[DOUBLE, DOUBLE, I32, PTR, I32, I32, I32], ); module.declare_function("js_fs_to_unix_timestamp", DOUBLE, &[DOUBLE]); module.declare_function("js_fs_write_file_sync", I32, &[DOUBLE, DOUBLE]); diff --git a/crates/perry-hir/src/ir/decl.rs b/crates/perry-hir/src/ir/decl.rs index 98a28d62f0..380750a82a 100644 --- a/crates/perry-hir/src/ir/decl.rs +++ b/crates/perry-hir/src/ir/decl.rs @@ -283,6 +283,25 @@ pub struct Class { pub specialized_from: Option, } +impl Class { + /// Whether evaluating this class creates any private names whose brands + /// must be distinct from every other evaluation of the same HIR template. + pub fn has_private_elements(&self) -> bool { + self.fields.iter().any(|field| field.is_private) + || self.static_fields.iter().any(|field| field.is_private) + || self + .methods + .iter() + .any(|method| method.name.starts_with('#')) + || self + .static_methods + .iter() + .any(|method| method.name.starts_with('#')) + || self.getters.iter().any(|(name, _)| name.starts_with('#')) + || self.setters.iter().any(|(name, _)| name.starts_with('#')) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ClassComputedMemberKind { Method, 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 54e96687af..69e2df4d0a 100644 --- a/crates/perry-hir/src/lower/lower_expr/arm_class.rs +++ b/crates/perry-hir/src/lower/lower_expr/arm_class.rs @@ -9,6 +9,7 @@ pub(crate) fn lower_class_expr( ctx: &mut LoweringContext, class_expr: &ast::ClassExpr, ) -> Result { + let assignment_name = ctx.assignment_inferred_name.clone(); let ident_name = class_expr.ident.as_ref().map(|i| i.sym.to_string()); // A NAMED class EXPRESSION used as a VALUE whose name collides // with an existing module-scope class — a TOP-LEVEL `class X` @@ -94,6 +95,7 @@ pub(crate) fn lower_class_expr( // guard (assigning to it inside the body throws a TypeError). ctx.pending_class_inner_name = class_expr.ident.as_ref().map(|i| i.sym.to_string()); let class = lower_class_from_ast(ctx, &class_expr.class, &synthetic_name, false)?; + let has_private_elements = class.has_private_elements(); if let Some(display) = display_override { ctx.class_display_names.insert(class.id, display); } @@ -165,12 +167,13 @@ pub(crate) fn lower_class_expr( .map(|m| m.name.clone()) .collect(); ctx.pending_classes.push(class); - // #1772: a class EXPRESSION that carries per-evaluation static - // fields and is NOT a mixin (`class extends `) lowers to a + // #1772/#5893: a class EXPRESSION that carries per-evaluation static + // fields, captures, or private elements lowers to a // fresh heap class object per evaluation (`ClassExprFresh`), so // `make(a) !== make(b)` and each holds its own statics as own - // properties. Mixins and class expressions without statics/captures - // keep the historical (shared-template) path. + // properties. Private elements need the same path because every class + // evaluation creates a distinct private brand, even though Perry keeps a + // shared compile-time template for method dispatch. // A class expression evaluated at module top level runs exactly // once, so it needs no per-evaluation freshness — route it through // the shared-template `ClassRef` path (identical to a class @@ -180,6 +183,18 @@ 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; + if !at_module_top && has_private_elements { + // `const C = class { #x }` normally records C as an inferred static + // class alias, which makes `new C()` bypass the local class VALUE. + // A private class evaluated in a function must construct through its + // fresh heap class object so the instance receives this evaluation's + // brand token. Keep the static alias optimization for all other class + // expressions and for module-top expressions (which evaluate once). + ctx.inferred_class_bindings.remove(&synthetic_name); + if let Some(name) = assignment_name.as_ref() { + ctx.inferred_class_bindings.remove(name); + } + } // #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 @@ -216,7 +231,8 @@ pub(crate) fn lower_class_expr( if !at_module_top && (!named_statics.is_empty() || !static_symbol_registrations.is_empty() - || !captured_args.is_empty()) + || !captured_args.is_empty() + || has_private_elements) { // #6438: a class expression WITH heritage (`class extends `) used // to be excluded here and fell back to the shared-template `ClassRef` diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index e5936eda7b..86d4094089 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -320,10 +320,10 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result Result = if fresh_binding { + class + .static_fields + .iter() + .filter_map( + |field| match (field.key_expr.as_ref(), field.init.as_ref()) { + (None, Some(value)) => Some((field.name.clone(), value.clone())), + _ => None, + }, + ) + .collect() + } else { + Vec::new() + }; + let symbol_statics: Vec<(Expr, Expr)> = if fresh_binding { + class + .static_fields + .iter() + .filter_map( + |field| match (field.key_expr.as_ref(), field.init.as_ref()) { + (Some(key), Some(value)) => Some((key.clone(), value.clone())), + _ => None, + }, + ) + .collect() + } else { + Vec::new() + }; // Static field initializers + static blocks for a // function-nested class. The module-level path // (`lower/stmt.rs`) emits these into `module.init`; here they @@ -342,22 +372,22 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result Result, + /// Static private-environment dispatch depth at each handler. A throw can + /// bypass a static method/accessor's normal pop, so catch entry restores + /// the stack to its handler-entry state. + static_private_owner_depths: Box<[usize]>, /// #6559: dyn-eval interpreter state (rooted-stack length + interpreter /// call depth, packed) captured when each `try` was pushed. A throw /// `longjmp`s past interpreter Rust frames without running their @@ -143,6 +147,7 @@ impl ExceptionState { runtime_handle_savepoints: vec![0usize; MAX_TRY_DEPTH].into_boxed_slice(), call_method_depths: vec![0u32; MAX_TRY_DEPTH].into_boxed_slice(), prototype_resolution_depths: vec![0usize; MAX_TRY_DEPTH].into_boxed_slice(), + static_private_owner_depths: vec![0usize; MAX_TRY_DEPTH].into_boxed_slice(), #[cfg(feature = "dyn-eval")] dyn_eval_savepoints: vec![0u64; MAX_TRY_DEPTH].into_boxed_slice(), try_depth: 0, @@ -202,6 +207,8 @@ fn try_push_with_kind(kind: HandlerKind) -> *mut i32 { (*s).call_method_depths[depth] = crate::object::call_method_depth_savepoint(); (*s).prototype_resolution_depths[depth] = crate::object::prototype_chain::resolution_stack_savepoint(); + (*s).static_private_owner_depths[depth] = + crate::object::static_private_owner_stack_savepoint(); // #6559: capture the dyn-eval interpreter's rooted-stack length + // call depth, so a caught throw restores interpreter state exactly // like the shadow stack. @@ -322,6 +329,7 @@ pub extern "C-unwind" fn js_throw(value: f64) -> ! { crate::object::prototype_chain::resolution_stack_restore( (*s).prototype_resolution_depths[depth], ); + crate::object::static_private_owner_stack_restore((*s).static_private_owner_depths[depth]); // #6559: restore the dyn-eval interpreter's rooted stack + call depth // (interpreter Rust frames unwound by this longjmp never run their // truncate/decrement epilogues). diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index b0746f23f3..13d72ebc9a 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -1641,12 +1641,11 @@ pub(super) unsafe fn gc_child_slots(header: *mut GcHeader) -> HeapChildSlotItera ) } GcLayoutSlotKind::ObjectMeta => { - // #6812: prototype (NaN-boxed / raw / sentinel) as the prefix - // slot, the raw spill-buffer pointer as a 1-slot range. Mirrors - // the rewrite descriptor arm — marking must see the same edges. + // Prototype is the prefix slot; spill and the private-evaluation + // brand are the two contiguous child slots that follow it. let meta = user_ptr as *mut crate::object::ObjectMeta; let proto_slot = Some(&mut (*meta).prototype as *mut u64); - let range = HeapSlotRange::new(&mut (*meta).spill as *mut u64, 1); + let range = HeapSlotRange::new(&mut (*meta).spill as *mut u64, 2); HeapChildSlotIterator::new(header, proto_slot, range) } GcLayoutSlotKind::ClosureCaptures => { diff --git a/crates/perry-runtime/src/gc/layout_slot_visit.rs b/crates/perry-runtime/src/gc/layout_slot_visit.rs index 3c52f991d5..e0ee81d4ff 100644 --- a/crates/perry-runtime/src/gc/layout_slot_visit.rs +++ b/crates/perry-runtime/src/gc/layout_slot_visit.rs @@ -307,6 +307,11 @@ pub(super) unsafe fn visit_gc_rewrite_slot_descriptors( // #6812: the object-owned overflow buffer is a raw-pointer child // edge (0 = none), traced and rewritten exactly like `prototype`. visit(fixed_slot(&mut (*meta).spill as *mut u64)); + // A fresh class object stored as an instance's private evaluation + // brand is a NaN-boxed child edge and moves with the meta record. + visit(fixed_slot( + &mut (*meta).private_evaluation_brand as *mut u64, + )); } GcRewriteDescriptorKind::Leaf => {} } diff --git a/crates/perry-runtime/src/gc/types.rs b/crates/perry-runtime/src/gc/types.rs index a707a8ab47..d539d815e2 100644 --- a/crates/perry-runtime/src/gc/types.rs +++ b/crates/perry-runtime/src/gc/types.rs @@ -935,10 +935,8 @@ pub(crate) fn validate_gc_type_info(info: &GcTypeInfo) -> Result<(), &'static st } } GcRewriteDescriptorKind::ObjectMeta => { - // #6812: meta records expose their two child edges (prototype, - // spill buffer) to MARKING via GcLayoutSlotKind::ObjectMeta — - // the spill buffer is reachable through meta alone, so a - // rewrite-only descriptor would leave it invisible to liveness. + // Meta records expose every child edge (prototype, spill buffer, + // and private-evaluation brand) to marking and rewriting. if info.layout_slot_kind != GcLayoutSlotKind::ObjectMeta { return Err("object-meta descriptor must expose its child edges to marking"); } diff --git a/crates/perry-runtime/src/object/class_constructors.rs b/crates/perry-runtime/src/object/class_constructors.rs index 8813e35608..a2738d1064 100644 --- a/crates/perry-runtime/src/object/class_constructors.rs +++ b/crates/perry-runtime/src/object/class_constructors.rs @@ -1120,6 +1120,11 @@ pub(crate) unsafe fn replay_class_object_constructor( args_ptr: *const f64, args_len: usize, ) { + // Callers scope their argument read with `with_mut_ptr`; establish this + // function's own roots before any constructor-replay path can allocate. + let scope = crate::gc::RuntimeHandleScope::new(); + let classobj_handle = scope.root_nanbox_f64(classobj_value); + let inst_handle = scope.root_raw_mut_ptr(inst); // Spec: a derived class with no own `constructor` gets the implicit // `constructor(...args) { super(...args) }` — the nearest ancestor's ctor // must run with the same argument list. `lookup_class_constructor` holds @@ -1147,7 +1152,9 @@ pub(crate) unsafe fn replay_class_object_constructor( let Some((ctor_ptr, total_params, sig_caps)) = found else { // #6469: all-implicit ctor chain to a native base — run the spec // default Error-init instead of silently constructing message-less. - default_error_init_for_implicit_chain(class_cid, inst, args_ptr, args_len); + inst_handle.with_mut_ptr::(|inst| { + default_error_init_for_implicit_chain(class_cid, inst, args_ptr, args_len); + }); return; }; @@ -1158,7 +1165,7 @@ pub(crate) unsafe fn replay_class_object_constructor( // decl-site snapshot (CLASS_CAPTURE_VALUES) via the fallback below. let caps_val = if ctor_cid == class_cid { crate::object::js_object_get_own_field_or_undef( - classobj_value, + classobj_handle.get_nanbox_f64(), b"__perry_ctor_caps".as_ptr(), 17, ) @@ -1248,17 +1255,19 @@ pub(crate) unsafe fn replay_class_object_constructor( }; final_args.push(v); } - let _ = call_vtable_method( - ctor_ptr, - inst as i64, - final_args.as_ptr(), - final_args.len(), - total_params, - false, - // Capture-forwarding constructor args are materialized positionally - // above (including any caps), so no trailing rest re-packing here. - false, - ); + inst_handle.with_mut_ptr::(|inst| { + let _ = call_vtable_method( + ctor_ptr, + inst as i64, + final_args.as_ptr(), + final_args.len(), + total_params, + false, + // Capture-forwarding constructor args are materialized positionally + // above (including any caps), so no trailing rest re-packing here. + false, + ); + }); } /// Replay a registered class declaration constructor for an INT32-tagged diff --git a/crates/perry-runtime/src/object/class_registry/construct.rs b/crates/perry-runtime/src/object/class_registry/construct.rs index 2b678d3468..1e751edb14 100644 --- a/crates/perry-runtime/src/object/class_registry/construct.rs +++ b/crates/perry-runtime/src/object/class_registry/construct.rs @@ -896,8 +896,12 @@ pub unsafe extern "C" fn js_new_function_construct( // initializers (literal AND captured) and the constructor body — // matching what the static `new ClassName()` path does inline. if is_class_object_value(func_value) { - let obj = - crate::value::JSValue::from_bits(func_value.to_bits()).as_pointer::(); + // Root the class object: its pointer is both the constructor value and + // the private-brand identity for the instance. + let scope = crate::gc::RuntimeHandleScope::new(); + let class_handle = scope.root_nanbox_f64(func_value); + let obj = crate::value::JSValue::from_bits(class_handle.get_nanbox_f64().to_bits()) + .as_pointer::(); let class_cid = js_object_get_class_id(obj); if class_cid != 0 { let inst = js_object_alloc( @@ -910,16 +914,29 @@ pub unsafe extern "C" fn js_new_function_construct( // is an unrooted receiver and this arm returns the pre-move // address. Reproduced by `new C()` where `C = mk()` is a class // EXPRESSION value. - let scope = crate::gc::RuntimeHandleScope::new(); let inst_handle = scope.root_raw_mut_ptr(inst); + // Every evaluation gets a distinct brand despite sharing its + // class id. Stamp it before replay, where private access may occur. + inst_handle.with_mut_ptr::(|inst| { + super::super::field_get_set::stamp_private_evaluation_brand( + inst, + class_handle.get_nanbox_f64(), + ); + }); // Replay the class's registered constructor (instance-field // initializers + body) on the fresh instance, filling the // capture params from the snapshotted `__perry_ctor_caps`. The // mechanism lives in `class_constructors` to keep this file under // the 2,000-line CI gate. - super::super::class_constructors::replay_class_object_constructor( - func_value, class_cid, inst, args_ptr, args_len, - ); + inst_handle.with_mut_ptr::(|inst| { + super::super::class_constructors::replay_class_object_constructor( + class_handle.get_nanbox_f64(), + class_cid, + inst, + args_ptr, + args_len, + ); + }); let inst: *mut ObjectHeader = inst_handle.get_raw_mut_ptr(); // `class X extends Request/Response {}` constructed via the dynamic // (class-expression value) path: the replayed ctor's `super()` diff --git a/crates/perry-runtime/src/object/class_registry/parent_static.rs b/crates/perry-runtime/src/object/class_registry/parent_static.rs index 5b7f328378..447d41dbd7 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -913,8 +913,10 @@ pub(crate) unsafe fn class_symbol_getter_value( } let result = if is_static { let prev_this = crate::object::js_implicit_this_set(receiver); + crate::object::static_private_owner_push(receiver); let f: extern "C" fn() -> f64 = std::mem::transmute(getter); let result = f(); + crate::object::static_private_owner_pop(); crate::object::js_implicit_this_set(prev_this); result } else { @@ -957,8 +959,10 @@ pub(crate) unsafe fn class_symbol_setter_apply( if setter != 0 { if is_static { let prev_this = crate::object::js_implicit_this_set(receiver); + crate::object::static_private_owner_push(receiver); let f: extern "C" fn(f64) -> f64 = std::mem::transmute(setter); let _ = f(value); + crate::object::static_private_owner_pop(); crate::object::js_implicit_this_set(prev_this); } else { let f: extern "C" fn(f64, f64) -> f64 = std::mem::transmute(setter); @@ -994,10 +998,16 @@ pub(crate) unsafe fn class_static_accessor_getter_value( if getter == 0 { return Some(f64::from_bits(crate::value::TAG_UNDEFINED)); } - let prev_this = crate::object::js_implicit_this_set(receiver); + // Static accessor bodies use the same receiver-resolving + // prologue as static methods. In particular, a fresh class + // expression must expose its per-evaluation class object as + // `this`, not the shared compile-time ClassRef. + crate::object::static_this_arm_if_unarmed(receiver); + crate::object::static_private_owner_push(receiver); let f: extern "C" fn() -> f64 = std::mem::transmute(getter); let result = f(); - crate::object::js_implicit_this_set(prev_this); + crate::object::static_private_owner_pop(); + crate::object::static_this_disarm(); return Some(result); } } @@ -1031,10 +1041,15 @@ pub(crate) unsafe fn class_static_accessor_setter_apply( if let Some(accessors) = map.get(&cid) { if let Some(&(_, setter)) = accessors.get(name) { if setter != 0 { - let prev_this = crate::object::js_implicit_this_set(receiver); + // Mirror the getter path: the compiled static-accessor + // prologue consumes this override and binds `this` to the + // actual constructor value for this evaluation. + crate::object::static_this_arm_if_unarmed(receiver); + crate::object::static_private_owner_push(receiver); let f: extern "C" fn(f64) -> f64 = std::mem::transmute(setter); let _ = f(value); - crate::object::js_implicit_this_set(prev_this); + crate::object::static_private_owner_pop(); + crate::object::static_this_disarm(); } return true; } @@ -1454,6 +1469,7 @@ pub unsafe extern "C" fn js_class_static_method_call( } if let Some((func_ptr, param_count, has_rest)) = lookup_static_method_in_chain(class_id, name) { let prev_this = crate::object::js_implicit_this_set(receiver); + crate::object::static_private_owner_push(receiver); // Receiver-sensitive static `this`: arm the one-shot override so the // method prologue (`js_static_this_resolve`) sees the DYNAMIC receiver // (e.g. subclass `D` for an inherited `D.f()`). If an outer @@ -1489,6 +1505,7 @@ pub unsafe extern "C" fn js_class_static_method_call( call_static_method(func_ptr, args_ptr, args_len, param_count) }; crate::object::static_this_disarm(); + crate::object::static_private_owner_pop(); crate::object::js_implicit_this_set(prev_this); return result; } diff --git a/crates/perry-runtime/src/object/field_get_set.rs b/crates/perry-runtime/src/object/field_get_set.rs index 8bcdc7a56a..40007be7a4 100644 --- a/crates/perry-runtime/src/object/field_get_set.rs +++ b/crates/perry-runtime/src/object/field_get_set.rs @@ -277,7 +277,7 @@ pub use has_property::{js_in_operator, js_object_has_property}; pub(crate) use ic_miss::primitive_proto_method_name_static; pub(crate) use ic_miss::{ bind_primitive_proto_method_static, is_array_method_value_name, set_method_value_name, - timer_handle_method_name_static, + stamp_private_evaluation_brand, timer_handle_method_name_static, }; pub use ic_miss::{ js_object_get_field_by_name_f64, js_object_get_field_by_property_id_f64, diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index 210da649d1..0f11788f02 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -867,9 +867,92 @@ mod sso_tests_1781 { } } +/// Stamp an instance constructed through a `ClassExprFresh` value with the +/// identity of that particular class evaluation. The brand lives in the +/// object's traced metadata record so it neither shifts user field slots nor +/// changes the instance's ShapeId / own-key enumeration. +pub(crate) unsafe fn stamp_private_evaluation_brand(obj: *mut ObjectHeader, class_value: f64) { + if obj.is_null() || !super::super::class_registry::is_class_object_value(class_value) { + return; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_handle = scope.root_raw_mut_ptr(obj); + let class_handle = scope.root_nanbox_f64(class_value); + let (meta, _) = + obj_handle.across_mut::(|| crate::object::object_meta_ensure(obj)); + let brand = class_handle.get_nanbox_f64().to_bits(); + (*meta).private_evaluation_brand = brand; + crate::gc::runtime_write_barrier_slot( + meta as usize, + &(*meta).private_evaluation_brand as *const u64 as usize, + brand, + ); +} + +/// Return the per-evaluation brand carried by `value`, provided it belongs to +/// `declaring_class_id`'s compile-time template. A fresh class object is its +/// own static brand; instances carry that object in the hidden slot above. +fn private_evaluation_brand(value: f64, declaring_class_id: u32) -> Option { + if declaring_class_id == 0 { + return None; + } + if super::super::class_registry::is_class_object_value(value) { + let object = JSValue::from_bits(value.to_bits()).as_pointer::(); + if !object.is_null() && js_object_get_class_id(object) == declaring_class_id { + return Some(value.to_bits()); + } + } + let value = JSValue::from_bits(value.to_bits()); + if !value.is_pointer() { + return None; + } + let object = value.as_pointer::(); + let brand = unsafe { + if object.is_null() || !crate::object::object_is_shaped(object) || (*object).meta.is_null() + { + return None; + } + f64::from_bits((*(*object).meta).private_evaluation_brand) + }; + if !super::super::class_registry::is_class_object_value(brand) { + return None; + } + let object = JSValue::from_bits(brand.to_bits()).as_pointer::(); + (!object.is_null() && js_object_get_class_id(object) == declaring_class_id) + .then_some(brand.to_bits()) +} + +/// If the lexical class evaluation can be recovered from `brand_owner`, +/// compare `obj` against that exact evaluation. `None` asks callers to retain +/// the existing template-class check for ordinary (single-evaluation) classes. +fn private_evaluation_brand_matches( + obj: f64, + brand_owner: f64, + declaring_class_id: u32, +) -> Option { + // A static method/accessor closes over the PrivateEnvironment of its class + // evaluation. Its visible `this` may be replaced by call/apply, so dispatch + // records the lexical owner separately from ambient IMPLICIT_THIS. + let brand_owner = if super::super::class_registry::is_class_object_value(brand_owner) { + let captured_owner = super::super::static_private_owner_current().unwrap_or(brand_owner); + if super::super::class_registry::is_class_object_value(captured_owner) + && private_evaluation_brand(captured_owner, declaring_class_id).is_some() + { + captured_owner + } else { + brand_owner + } + } else { + brand_owner + }; + let expected = private_evaluation_brand(brand_owner, declaring_class_id)?; + Some(private_evaluation_brand(obj, declaring_class_id) == Some(expected)) +} + #[no_mangle] pub extern "C" fn js_private_brand_check( obj: f64, + brand_owner: f64, declaring_class_id: u32, field_name_ptr: *const u8, field_name_len: u32, @@ -880,32 +963,9 @@ pub extern "C" fn js_private_brand_check( return false_value; } - let value = JSValue::from_bits(obj.to_bits()); - if !value.is_pointer() { - return false_value; - } - let obj_ptr = value.as_pointer::(); - if obj_ptr.is_null() { - return false_value; - } - - let obj_class_id = js_object_get_class_id(obj_ptr); - if obj_class_id == 0 { - return false_value; - } - - let mut cur = obj_class_id; - let mut has_declaring_brand = false; - for _ in 0..32 { - if cur == declaring_class_id { - has_declaring_brand = true; - break; - } - match super::super::class_registry::get_parent_class_id(cur) { - Some(parent) if parent != 0 && parent != cur => cur = parent, - _ => break, - } - } + let has_declaring_brand = + private_evaluation_brand_matches(obj, brand_owner, declaring_class_id) + .unwrap_or_else(|| unsafe { private_object_has_brand(obj, declaring_class_id) }); if !has_declaring_brand { return false_value; } @@ -982,6 +1042,7 @@ unsafe fn private_object_has_brand(obj: f64, declaring_class_id: u32) -> bool { #[no_mangle] pub extern "C" fn js_private_guard( obj: f64, + brand_owner: f64, declaring_class_id: u32, _field_name_ptr: *const u8, _field_name_len: u32, @@ -993,13 +1054,17 @@ pub extern "C" fn js_private_guard( } let is_static = op >= 2; let read_write = op & 1; // 0=read, 1=write - let has_brand = if is_static { - // Static private brand: the receiver must be exactly the declaring - // class constructor (identity), not an instance or a subclass. - super::super::class_ref_id(obj) == Some(declaring_class_id) - } else { - unsafe { private_object_has_brand(obj, declaring_class_id) } - }; + let has_brand = private_evaluation_brand_matches(obj, brand_owner, declaring_class_id) + .unwrap_or_else(|| { + if is_static { + // Static private brand: the receiver must be exactly the + // declaring class constructor (identity), not an instance or + // a subclass. + super::super::class_ref_id(obj) == Some(declaring_class_id) + } else { + unsafe { private_object_has_brand(obj, declaring_class_id) } + } + }); if !has_brand { throw_private_type_error( "Cannot access private member from an object whose class did not declare it", @@ -1019,6 +1084,45 @@ pub extern "C" fn js_private_guard( obj } +#[cfg(test)] +mod private_evaluation_brand_tests { + use super::*; + + #[test] + fn stamping_a_fresh_brand_preserves_instance_shape_and_slots() { + unsafe { + const CID: u32 = 62_441; + let class = crate::object::js_object_alloc(CID, 0); + crate::object::class_registry::js_object_mark_class(class as i64); + let class_value = crate::value::js_nanbox_pointer(class as i64); + assert!(crate::object::class_registry::is_class_object_value( + class_value + )); + + let instance = crate::object::js_object_alloc(CID, 2); + let shape_before = crate::object::shapes::object_shape_id(instance); + let keys_before = crate::object::object_keys_array(instance); + let slots_before = crate::object::object_live_slot_count(instance); + + stamp_private_evaluation_brand(instance, class_value); + + assert_eq!( + crate::object::shapes::object_shape_id(instance), + shape_before + ); + assert_eq!(crate::object::object_keys_array(instance), keys_before); + assert_eq!( + crate::object::object_live_slot_count(instance), + slots_before + ); + assert_eq!( + private_evaluation_brand(crate::value::js_nanbox_pointer(instance as i64), CID), + Some(class_value.to_bits()) + ); + } + } +} + #[cfg(test)] mod poly_pic_tests { use super::{pic_prime_get, PicCache, PIC_CACHE_WORDS, PIC_WAYS, PIC_WAY_BASE, PIC_WAY_STATE}; diff --git a/crates/perry-runtime/src/object/field_set_by_name/tail.rs b/crates/perry-runtime/src/object/field_set_by_name/tail.rs index e6be57a8ad..d891ef3ee7 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/tail.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/tail.rs @@ -266,6 +266,49 @@ pub(super) fn set_field_by_name_object_tail( let plan_fast = plan_eligible && super::prop_plan::store_plan_check(obj_class_id, interned_key as usize); + // A per-evaluation class expression is a heap class object rather than + // the INT32 ClassRef handled in the entry-point prelude. Private static + // setter assignments (`this.#m = value`) still need to consult the + // template class's registered static accessor, while passing THIS + // evaluation's class object as the receiver. Private names are scoped + // here deliberately: an ordinary string property literally named + // "#m" remains a separate public data property. + // `js_string_intern` above can allocate and evacuate all three rooted + // operands. Refresh before the first class-object/private-name probe; + // every dereference and the user-visible setter call below is then + // dominated by the post-allocation reload. + let private_static = obj_handle.with_mut_ptr::(|obj| { + key_handle.with_const_ptr::(|key| { + if !crate::object::is_class_object_ptr(obj as *const u8) + || key.is_null() + || !crate::value::addr_class::is_above_handle_band(key as usize) + { + return None; + } + let name_ptr = crate::string::string_data(key); + let name_len = (*key).byte_len as usize; + let name = + std::str::from_utf8(std::slice::from_raw_parts(name_ptr, name_len)).ok()?; + name.starts_with('#').then(|| { + ( + (*obj).class_id, + name.to_string(), + crate::value::js_nanbox_pointer(obj as i64), + ) + }) + }) + }); + if let Some((class_id, name, receiver)) = private_static { + if super::class_registry::class_static_accessor_setter_apply( + class_id, + &name, + receiver, + value_handle.get_nanbox_f64(), + ) { + return; + } + } + // Refs #486 (hono): class setter dispatch. JS spec: a `set X(...)` // accessor on the prototype intercepts `obj.X = value` writes // before they hit the instance's data slots. Hono's `set res(_res) diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 41fd90b782..0456cb30fd 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -252,8 +252,10 @@ pub use this_binding::{ js_static_this_resolve, }; pub(crate) use this_binding::{ - scan_implicit_this_roots_mut, static_this_arm, static_this_arm_if_unarmed, static_this_disarm, - IMPLICIT_THIS, + scan_implicit_this_roots_mut, static_private_owner_current, static_private_owner_pop, + static_private_owner_push, static_private_owner_stack_restore, + static_private_owner_stack_savepoint, static_this_arm, static_this_arm_if_unarmed, + static_this_disarm, IMPLICIT_THIS, }; pub use to_string_tag::js_object_to_string; pub(crate) use to_string_tag::typed_array_to_string_tag_name; @@ -1742,6 +1744,11 @@ pub struct ObjectMeta { /// pointer-keyed side state, no owner re-keying on evacuation, no /// per-object finalization. pub spill: u64, + /// Fresh ClassDefinitionEvaluation identity for instances constructed + /// from a heap class object. This is object metadata rather than an own + /// property: private branding must not consume a user field slot, alter + /// the ShapeId/key order, or become visible to enumeration. + pub private_evaluation_brand: u64, } pub(crate) const OBJECT_META_FLAG_PROTO_OVERRIDE: u64 = 1; @@ -1815,6 +1822,7 @@ pub(crate) unsafe fn object_meta_ensure(obj: *mut ObjectHeader) -> *mut ObjectMe (*meta).accessor_key_bits = 0; (*meta).flags = 0; (*meta).spill = 0; + (*meta).private_evaluation_brand = 0; // GC_STORE_AUDIT(BARRIERED): meta-record edge is a header-slot store // followed by an object-slot barrier, mirroring `set_object_keys_array`. (*obj).meta = meta; diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index 70ddacc504..04e53809a8 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -1501,11 +1501,12 @@ pub(crate) fn build_symbol_bound_method_closure( /// receiver (an instance or class ref). Otherwise the captured value is the real /// receiver and is returned unchanged. See `dispatch_bound_method`. /// Is `value` a bound STATIC-method value — a BOUND_METHOD closure whose -/// captured receiver is a class constructor ref (`C.staticMethod` read as a -/// value)? Used by the Function.prototype call/apply arms to arm the one-shot -/// static-`this` override with the explicit thisArg, so the static method body -/// sees the receiver (`C.m.call({})` → `this === {}`) and static private brand -/// checks behave per spec. +/// captured receiver is a class constructor ref or a per-evaluation class +/// object (`C.staticMethod` read as a value)? Used by the Function.prototype +/// call/apply arms to arm the one-shot static-`this` override with the explicit +/// thisArg, so the static method body sees the receiver +/// (`C.m.call({})` → `this === {}`) and static private brand checks behave per +/// spec. pub(crate) fn is_static_bound_method_value(value: f64) -> bool { let jv = JSValue::from_bits(value.to_bits()); if !jv.is_pointer() { @@ -1523,7 +1524,8 @@ pub(crate) fn is_static_bound_method_value(value: f64) -> bool { return false; } let captured = crate::closure::js_closure_get_capture_f64(closure, 0); - class_ref_id(captured).is_some() && class_prototype_ref_id(captured).is_none() + (class_ref_id(captured).is_some() && class_prototype_ref_id(captured).is_none()) + || class_registry::is_class_object_value(captured) } pub(crate) fn canonical_bound_method_receiver(captured: f64) -> f64 { diff --git a/crates/perry-runtime/src/object/this_binding.rs b/crates/perry-runtime/src/object/this_binding.rs index 44e3de9ce4..bd8b5cdc27 100644 --- a/crates/perry-runtime/src/object/this_binding.rs +++ b/crates/perry-runtime/src/object/this_binding.rs @@ -3,7 +3,7 @@ use super::*; -use std::cell::Cell; +use std::cell::{Cell, RefCell}; // Implicit `this` for closure-typed class fields invoked method-style. // @@ -37,6 +37,33 @@ crate::perry_thread_local! { // Direct compiled calls never arm it, so they keep the lexical class-ref. static STATIC_THIS_OVERRIDE: Cell<(bool, u64)> = const { Cell::new((false, crate::value::TAG_UNDEFINED)) }; + /// Lexical ClassDefinitionEvaluation owner for static method/accessor + /// dispatch. Unlike IMPLICIT_THIS, this is not replaced by `.call`'s + /// visible receiver. A stack makes nested dispatch frame-local. + static STATIC_PRIVATE_OWNER_STACK: RefCell> = + const { RefCell::new(Vec::new()) }; +} + +pub(crate) fn static_private_owner_push(value: f64) { + STATIC_PRIVATE_OWNER_STACK.with(|stack| stack.borrow_mut().push(value.to_bits())); +} + +pub(crate) fn static_private_owner_pop() { + STATIC_PRIVATE_OWNER_STACK.with(|stack| { + stack.borrow_mut().pop(); + }); +} + +pub(crate) fn static_private_owner_current() -> Option { + STATIC_PRIVATE_OWNER_STACK.with(|stack| stack.borrow().last().copied().map(f64::from_bits)) +} + +pub(crate) fn static_private_owner_stack_savepoint() -> usize { + STATIC_PRIVATE_OWNER_STACK.with(|stack| stack.borrow().len()) +} + +pub(crate) fn static_private_owner_stack_restore(depth: usize) { + STATIC_PRIVATE_OWNER_STACK.with(|stack| stack.borrow_mut().truncate(depth)); } /// Arm the static-`this` override unconditionally (used by the call/apply @@ -219,4 +246,9 @@ pub fn scan_implicit_this_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor< c.set((armed, bits)); } }); + STATIC_PRIVATE_OWNER_STACK.with(|stack| { + for bits in stack.borrow_mut().iter_mut() { + visitor.visit_nanbox_u64_slot(bits); + } + }); } diff --git a/test-files/test_issue_5893_private_brand_freshness.ts b/test-files/test_issue_5893_private_brand_freshness.ts new file mode 100644 index 0000000000..3437d9b303 --- /dev/null +++ b/test-files/test_issue_5893_private_brand_freshness.ts @@ -0,0 +1,214 @@ +function check(label: string, condition: boolean): void { + console.log(label + ": " + condition.toString()); +} + +function throwsTypeError(callback: () => void): boolean { + try { + callback(); + return false; + } catch (error) { + return error instanceof TypeError; + } +} + +function makeDeclarationInstance(): any { + class C { + #value = "test262"; + + #method(): string { + return this.#value; + } + + get #getter(): string { + return this.#value; + } + + set #setter(value: string) { + this.#value = value; + } + + readMethod(other: C): string { + return other.#method(); + } + + readGetter(other: C): string { + return other.#getter; + } + + writeSetter(other: C, value: string): void { + other.#setter = value; + } + + hasValue(other: any): boolean { + return #value in other; + } + } + + return new C(); +} + +function makeExpressionInstance(): any { + const C = class { + #value = "test262"; + + #method(): string { + return this.#value; + } + + get #getter(): string { + return this.#value; + } + + set #setter(value: string) { + this.#value = value; + } + + readMethod(other: any): string { + return other.#method(); + } + + readGetter(other: any): string { + return other.#getter; + } + + writeSetter(other: any, value: string): void { + other.#setter = value; + } + + hasValue(other: any): boolean { + return #value in other; + } + }; + + return new C(); +} + +function checkFreshBrands(label: string, make: () => any): void { + const first = make(); + const second = make(); + + check(label + " own method", first.readMethod(first) === "test262"); + check(label + " own getter", second.readGetter(second) === "test262"); + first.writeSetter(first, "changed"); + check(label + " own setter", first.readGetter(first) === "changed"); + check(label + " own in", first.hasValue(first)); + check(label + " cross-evaluation in", !first.hasValue(second)); + + check( + label + " cross-evaluation method", + throwsTypeError(() => first.readMethod(second)) + ); + check( + label + " cross-evaluation getter", + throwsTypeError(() => first.readGetter(second)) + ); + check( + label + " cross-evaluation setter", + throwsTypeError(() => first.writeSetter(second, "wrong")) + ); +} + +checkFreshBrands("declaration", makeDeclarationInstance); +checkFreshBrands("expression", makeExpressionInstance); + +function makeStaticClass(): any { + return class { + static #value = "test262"; + static _written = ""; + + static #method(): string { + return this.#value; + } + + static get #getter(): string { + return this.#value; + } + + static set #setter(value: string) { + this._written = value; + } + + static accessMethod(): string { + return this.#method(); + } + + static accessGetter(): string { + return this.#getter; + } + + static accessSetter(value: string): void { + this.#setter = value; + } + + static hasValue(other: any): boolean { + return #value in other; + } + }; +} + +function makeStaticDeclarationClass(): any { + class StaticDeclarationC { + static #value = "test262"; + static _written = ""; + + static #method(): string { + return this.#value; + } + + static get #getter(): string { + return this.#value; + } + + static set #setter(value: string) { + this._written = value; + } + + static accessMethod(): string { + return this.#method(); + } + + static accessGetter(): string { + return this.#getter; + } + + static accessSetter(value: string): void { + this.#setter = value; + } + + static hasValue(other: any): boolean { + return #value in other; + } + } + + return StaticDeclarationC; +} + +function checkFreshStaticBrands(label: string, make: () => any): void { + const first = make(); + const second = make(); + check(label + " own method", first.accessMethod() === "test262"); + check(label + " own getter", second.accessGetter() === "test262"); + first.accessSetter("changed"); + check(label + " own setter", first._written === "changed"); + check(label + " own in", first.hasValue(first)); + check(label + " cross-evaluation in", !first.hasValue(second)); + check( + label + " cross-evaluation method", + throwsTypeError(() => first.accessMethod.call(second)) + ); + check( + label + " cross-evaluation getter", + throwsTypeError(() => first.accessGetter.call(second)) + ); + check( + label + " own getter after throw", + first.accessGetter() === "test262" + ); + check( + label + " cross-evaluation setter", + throwsTypeError(() => first.accessSetter.call(second, "wrong")) + ); +} + +checkFreshStaticBrands("static expression", makeStaticClass); +checkFreshStaticBrands("static declaration", makeStaticDeclarationClass);