From 8caf8c982bcf768de619d9c7df1e80b0ee72756d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 26 Sep 2026 12:28:59 +0000 Subject: [PATCH 01/29] refactor(runtime): one reader for "which class does this constructor value name" Stage 0 of making class constructors real function objects (#11414). Every decoder of a class-constructor value now asks object::class_value (class_value_id / class_value_id_bits / class_closure_id), which accepts the legacy INT32 immediate and the class function object form (a GC_TYPE_CLOSURE whose code pointer is js_class_constructor_called, its [[Call]] that throws, and whose capture slot 0 holds the class id). class_ref_id and constructor_class_ref_id are defined through it; the raw `>> 48 == 0x7FFE` class gates go through legacy_class_value_word / legacy_class_ptr_word, which keep the old gate's exact INT32 behaviour (the #11414 sites stage 5 narrows) and also admit the function-object form. Nothing produces the function-object form yet: no behaviour change. --- .../perry-runtime/src/array/indexing_keyed.rs | 2 +- .../src/closure/dispatch/bound.rs | 14 ++- crates/perry-runtime/src/dyn_eval/expr.rs | 16 ++- .../src/object/class_registry/class_meta.rs | 4 +- .../src/object/class_registry/construct.rs | 5 +- .../object/class_registry/parent_static.rs | 5 +- .../perry-runtime/src/object/class_value.rs | 113 ++++++++++++++++++ .../object/field_get_set/get_field_by_name.rs | 8 +- .../src/object/field_get_set/has_property.rs | 3 +- .../src/object/field_set_by_name.rs | 8 +- .../object/field_set_by_name/attr_variants.rs | 4 +- .../src/object/global_this/bigint_promise.rs | 2 +- crates/perry-runtime/src/object/mod.rs | 3 + .../src/object/native_call_method.rs | 4 +- .../native_call_method/common_methods.rs | 5 +- .../native_call_method/primitive_methods.rs | 3 +- .../object/native_module/class_ref_values.rs | 12 +- .../src/object/object_ops/has_own.rs | 2 +- .../src/object/object_ops/prototype.rs | 3 +- .../src/proxy/apply_construct.rs | 2 +- crates/perry-runtime/src/symbol/get.rs | 3 +- crates/perry-runtime/src/symbol/properties.rs | 11 +- crates/perry-runtime/src/typed_feedback.rs | 2 +- crates/perry-runtime/src/value/dyn_index.rs | 4 +- 24 files changed, 176 insertions(+), 62 deletions(-) create mode 100644 crates/perry-runtime/src/object/class_value.rs diff --git a/crates/perry-runtime/src/array/indexing_keyed.rs b/crates/perry-runtime/src/array/indexing_keyed.rs index e8894906f8..c408486d64 100644 --- a/crates/perry-runtime/src/array/indexing_keyed.rs +++ b/crates/perry-runtime/src/array/indexing_keyed.rs @@ -37,7 +37,7 @@ pub extern "C" fn js_array_set_string_key( // its high bits are set, so the `is_array` GC-header probe below would // dereference unmapped memory. Route to the by-name object setter, which // detects the class-ref tag and stores into the static-field tables. - if (arr as u64) >> 48 == 0x7FFE { + if crate::object::class_value::legacy_class_ptr_word(arr as u64).is_some() { crate::object::js_object_set_field_by_name( arr as *mut crate::object::ObjectHeader, key, diff --git a/crates/perry-runtime/src/closure/dispatch/bound.rs b/crates/perry-runtime/src/closure/dispatch/bound.rs index 46f33ad5ec..660189a090 100644 --- a/crates/perry-runtime/src/closure/dispatch/bound.rs +++ b/crates/perry-runtime/src/closure/dispatch/bound.rs @@ -493,9 +493,10 @@ unsafe fn bound_target_declared_name(target_value: f64) -> String { return String::new(); } let target_class_id = crate::object::class_ref_id(target_value).or_else(|| { - ((target_value.to_bits() >> 48) == 0x7FFE - && crate::object::class_prototype_ref_id(target_value).is_none()) - .then_some((target_value.to_bits() & 0xFFFF_FFFF) as u32) + crate::object::class_prototype_ref_id(target_value) + .is_none() + .then(|| crate::object::class_value::legacy_class_value_word(target_value.to_bits())) + .flatten() }); target_class_id .and_then(crate::object::class_name_for_id) @@ -615,9 +616,10 @@ pub unsafe extern "C" fn js_function_bind( crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64)); } let target_class_id = crate::object::class_ref_id(target_value).or_else(|| { - ((target_value.to_bits() >> 48) == 0x7FFE - && crate::object::class_prototype_ref_id(target_value).is_none()) - .then_some((target_value.to_bits() & 0xFFFF_FFFF) as u32) + crate::object::class_prototype_ref_id(target_value) + .is_none() + .then(|| crate::object::class_value::legacy_class_value_word(target_value.to_bits())) + .flatten() }); let target_is_closure = if target_jv.is_pointer() { let ptr = target_jv.as_pointer::(); diff --git a/crates/perry-runtime/src/dyn_eval/expr.rs b/crates/perry-runtime/src/dyn_eval/expr.rs index c2b77405b5..d1024c29dc 100644 --- a/crates/perry-runtime/src/dyn_eval/expr.rs +++ b/crates/perry-runtime/src/dyn_eval/expr.rs @@ -808,8 +808,11 @@ fn eval_call(ctx: &Ctx, c: &ast::CallExpr, env_idx: usize) -> f64 { } let callee_idx = (!ctx.strings_allowed && (crate::object::js_value_is_heap_object(root_get(obj_idx)) - || (root_get(obj_idx).to_bits() >> 48) == 0x7FFE)) - .then(|| root_push(bridge::get_member(root_get(obj_idx), &name))); + || crate::object::class_value::legacy_class_value_word( + root_get(obj_idx).to_bits(), + ) + .is_some())) + .then(|| root_push(bridge::get_member(root_get(obj_idx), &name))); let codegen_blocked = !ctx.strings_allowed && ((name == "constructor" && crate::object::value_is_callable(root_get(obj_idx))) @@ -851,10 +854,11 @@ fn eval_call(ctx: &Ctx, c: &ast::CallExpr, env_idx: usize) -> f64 { let key_idx = root_push(key); let callee_idx = (!ctx.strings_allowed && (crate::object::js_value_is_heap_object(root_get(obj_idx)) - || (root_get(obj_idx).to_bits() >> 48) == 0x7FFE)) - .then(|| { - root_push(bridge::get_index(root_get(obj_idx), root_get(key_idx))) - }); + || crate::object::class_value::legacy_class_value_word( + root_get(obj_idx).to_bits(), + ) + .is_some())) + .then(|| root_push(bridge::get_index(root_get(obj_idx), root_get(key_idx)))); let codegen_blocked = callee_idx.is_some_and(|idx| is_string_codegen_callee(ctx, root_get(idx))); let method = bridge::read_string(root_get(key_idx)).unwrap_or_default(); diff --git a/crates/perry-runtime/src/object/class_registry/class_meta.rs b/crates/perry-runtime/src/object/class_registry/class_meta.rs index 76b9fcfe94..fd1f457868 100644 --- a/crates/perry-runtime/src/object/class_registry/class_meta.rs +++ b/crates/perry-runtime/src/object/class_registry/class_meta.rs @@ -254,8 +254,8 @@ pub(crate) fn dispatch_diag_enabled() -> bool { fn describe_dispatch_receiver(recv: f64) -> String { let bits = recv.to_bits(); let top16 = bits >> 48; - if top16 == 0x7FFE { - let cid = (bits & 0xFFFF_FFFF) as u32; + let _ = top16; + if let Some(cid) = crate::object::class_value::legacy_class_value_word(bits) { return match class_name_for_id(cid) { Some(n) => format!("class-ref `{}` (id {})", n, cid), None => format!("class-ref (id {})", cid), diff --git a/crates/perry-runtime/src/object/class_registry/construct.rs b/crates/perry-runtime/src/object/class_registry/construct.rs index b56a78ed0f..75df56e279 100644 --- a/crates/perry-runtime/src/object/class_registry/construct.rs +++ b/crates/perry-runtime/src/object/class_registry/construct.rs @@ -1250,10 +1250,7 @@ pub unsafe extern "C" fn js_new_function_construct_apply(func_value: f64, args_a } fn constructor_class_ref_id(value: f64) -> Option { - if super::super::class_prototype_ref_id(value).is_some() { - return None; - } - super::super::class_ref_id(value) + super::super::class_value_id(value) } /// Spec `IsConstructor(value)` — used by `NewPromiseCapability` (the Promise 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 73ade58983..f0ea5480c0 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -1611,8 +1611,9 @@ pub unsafe extern "C" fn js_class_static_method_call( // class_id stamped on a POINTER class object's ObjectHeader. let bits = receiver.to_bits(); let top16 = bits >> 48; - let class_id = if top16 == 0x7FFE { - (bits & 0xFFFF_FFFF) as u32 + let _ = top16; + let class_id = if let Some(cid) = crate::object::class_value::legacy_class_value_word(bits) { + cid } else if is_class_object_value(receiver) { let obj = crate::value::JSValue::from_bits(bits).as_pointer::(); js_object_get_class_id(obj) diff --git a/crates/perry-runtime/src/object/class_value.rs b/crates/perry-runtime/src/object/class_value.rs new file mode 100644 index 0000000000..f49dc4e829 --- /dev/null +++ b/crates/perry-runtime/src/object/class_value.rs @@ -0,0 +1,113 @@ +//! A class CONSTRUCTOR used as a value (#11414; the every-receiver-shape +//! lane's class-constructor stage). +//! +//! Two forms name a class constructor while the migration runs: +//! +//! * the legacy INT32 immediate `0x7FFE_0000_0000_0000 | class_id` with bit 32 +//! clear (bit 32 set is the `C.prototype` half — [`super::class_prototype_ref_id`]). +//! It is bit-identical to the int32 number equal to the class id, which is +//! #11414; the lane deletes it; +//! * a class FUNCTION OBJECT: a `GC_TYPE_CLOSURE` cell whose code pointer is +//! [`js_class_constructor_called`] (its [[Call]], which throws) and whose +//! capture slot 0 holds the class id as an INT32 value. +//! +//! Every decoder asks [`class_value_id`] (or [`class_value_id_bits`] / +//! [`class_closure_id`] for the raw-word and raw-pointer spellings). Nothing +//! else may test the INT32 tag or the code pointer to decide "is this a class". +use crate::closure::ClosureHeader; + +/// The class a constructor VALUE names — either form — or `None`. A +/// `C.prototype` reference is not a constructor and answers `None`. +#[inline] +pub(crate) fn class_value_id(value: f64) -> Option { + class_value_id_bits(value.to_bits()) +} + +/// [`class_value_id`] on the NaN-boxed word. +#[inline] +pub(crate) fn class_value_id_bits(bits: u64) -> Option { + match bits >> 48 { + 0x7FFE => { + if bits & super::native_module::CLASS_PROTOTYPE_REF_FLAG != 0 { + return None; + } + let class_id = (bits & 0xFFFF_FFFF) as u32; + (class_id != 0 && super::is_class_id_registered(class_id)).then_some(class_id) + } + 0x7FFD => class_closure_id((bits & crate::value::POINTER_MASK) as usize), + _ => None, + } +} + +/// The class id of a class function object at raw address `ptr`, or `None` +/// for any other word. Ownership is proven (`is_closure_ptr`) before a header +/// byte is trusted, so arbitrary addresses are fine. +#[inline] +pub fn class_closure_id(ptr: usize) -> Option { + if !crate::closure::is_closure_ptr(ptr) { + return None; + } + // SAFETY: `is_closure_ptr` proved a live, non-forwarded closure cell. + unsafe { class_closure_id_unchecked(ptr as *const ClosureHeader) } +} + +/// [`class_closure_id`] for a cell already proven to be a live closure. +/// +/// # Safety +/// `closure` is a live, non-forwarded `GC_TYPE_CLOSURE` cell. +#[inline] +pub(crate) unsafe fn class_closure_id_unchecked(closure: *const ClosureHeader) -> Option { + if (*closure).func_ptr != js_class_constructor_called as *const u8 { + return None; + } + let slot0 = *((closure as *const u8).add(std::mem::size_of::()) as *const u64); + Some((slot0 & 0xFFFF_FFFF) as u32) +} + +/// [[Call]] of a class constructor: ES2015 9.2.1 step 2 — a class constructor +/// called without `new` throws a TypeError. It is the code pointer of every +/// class function object (and how one is recognized); [[Construct]] never +/// reaches it — `new` decodes the class id and runs the class's constructor. +#[no_mangle] +pub unsafe extern "C" fn js_class_constructor_called(closure: *const ClosureHeader) -> f64 { + let name = unsafe { class_closure_id_unchecked(closure) } + .and_then(super::class_registry::class_name_for_id) + .unwrap_or_default(); + let message = format!("Class constructor {name} cannot be invoked without 'new'"); + crate::node_submodules::diagnostics::throw_type_error_no_code(message.as_bytes()) +} + +/// The PRE-MIGRATION gate spelling, kept exact for the legacy form while it +/// also admits the function-object form: any INT32 word (registered or not, +/// either half — those gates accepted every `0x7FFE` word, which is #11414) +/// or a class function object. `bits` is a NaN-boxed VALUE word. Every caller +/// is narrowed to [`class_value_id_bits`] when the INT32 form is deleted. +#[inline] +pub(crate) fn legacy_class_value_word(bits: u64) -> Option { + match bits >> 48 { + 0x7FFE => Some((bits & 0xFFFF_FFFF) as u32), + 0x7FFD => class_closure_id((bits & crate::value::POINTER_MASK) as usize), + _ => None, + } +} + +/// [`legacy_class_value_word`] for a word that arrived through a POINTER-typed +/// parameter (`obj as u64`), which may be a raw untagged heap address. +#[inline] +pub(crate) fn legacy_class_ptr_word(bits: u64) -> Option { + match bits >> 48 { + 0 => class_closure_id(bits as usize), + _ => legacy_class_value_word(bits), + } +} + +/// The NaN-boxed VALUE for a word [`legacy_class_ptr_word`] admitted: a raw +/// heap address gets its POINTER tag, anything else is already a value. +#[inline] +pub(crate) fn boxed_class_word(bits: u64) -> f64 { + if bits >> 48 == 0 { + f64::from_bits(crate::value::POINTER_TAG | bits) + } else { + f64::from_bits(bits) + } +} diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index 8609d202ae..00d56a5fc8 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -1290,9 +1290,11 @@ pub(crate) fn get_field_by_name_past_inherited_cache( // `SQL.Aliased` lookup pattern. { let bits = obj as u64; - if (bits >> 48) == 0x7FFE && !key.is_null() { - let class_id = (bits & 0xFFFF_FFFF) as u32; - let class_value = f64::from_bits(bits); + if let (Some(class_id), false) = ( + crate::object::class_value::legacy_class_ptr_word(bits), + key.is_null(), + ) { + let class_value = crate::object::class_value::boxed_class_word(bits); let is_prototype_ref = super::super::class_prototype_ref_id(class_value).is_some(); unsafe { let name_ptr = (key as *const u8).add(std::mem::size_of::()); diff --git a/crates/perry-runtime/src/object/field_get_set/has_property.rs b/crates/perry-runtime/src/object/field_get_set/has_property.rs index d3297da56e..205b312296 100644 --- a/crates/perry-runtime/src/object/field_get_set/has_property.rs +++ b/crates/perry-runtime/src/object/field_get_set/has_property.rs @@ -411,8 +411,7 @@ pub extern "C" fn js_object_has_property(obj: f64, key: f64) -> f64 { // keys and CLASS_DYNAMIC_PROPS for string keys. { let bits = obj.to_bits(); - if (bits >> 48) == 0x7FFE { - let class_id = (bits & 0xFFFF_FFFF) as u32; + if let Some(class_id) = crate::object::class_value::legacy_class_value_word(bits) { // Symbol key path. if crate::symbol::class_static_symbol_lookup(class_id, key).is_some() { return nanbox_true; diff --git a/crates/perry-runtime/src/object/field_set_by_name.rs b/crates/perry-runtime/src/object/field_set_by_name.rs index f58f9664aa..ea1e573a1c 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -426,8 +426,10 @@ pub extern "C" fn js_object_set_field_by_name( // so a later `SQL.Aliased` read can find it. { let bits = obj as u64; - if (bits >> 48) == 0x7FFE && !key.is_null() { - let class_id = (bits & 0xFFFF_FFFF) as u32; + if let (Some(class_id), false) = ( + crate::object::class_value::legacy_class_ptr_word(bits), + key.is_null(), + ) { unsafe { let name_ptr = (key as *const u8).add(std::mem::size_of::()); @@ -435,7 +437,7 @@ pub extern "C" fn js_object_set_field_by_name( let name = std::str::from_utf8(std::slice::from_raw_parts(name_ptr, name_len)) .unwrap_or("") .to_string(); - let recv = f64::from_bits(bits); + let recv = crate::object::class_value::boxed_class_word(bits); let is_prototype_ref = super::class_prototype_ref_id(recv).is_some(); if !is_prototype_ref && name == "name" diff --git a/crates/perry-runtime/src/object/field_set_by_name/attr_variants.rs b/crates/perry-runtime/src/object/field_set_by_name/attr_variants.rs index 848bae6dc3..49f876ca4a 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/attr_variants.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/attr_variants.rs @@ -23,7 +23,7 @@ pub extern "C" fn js_object_set_field_by_name_nonenum( // TypedArrays, Temporal cells, etc. are handled by `set_field_by_name`'s own // routing and never reach the ordinary enumerable default, so skip them. let bits = obj as u64; - if (bits >> 48) == 0x7FFE + if crate::object::class_value::legacy_class_ptr_word(bits).is_some() || crate::value::addr_class::is_handle_band(obj as usize) || key.is_null() { @@ -64,7 +64,7 @@ pub extern "C" fn js_object_set_field_by_name_nonconfigurable( ) { js_object_set_field_by_name(obj, key, value); let bits = obj as u64; - if (bits >> 48) == 0x7FFE + if crate::object::class_value::legacy_class_ptr_word(bits).is_some() || crate::value::addr_class::is_handle_band(obj as usize) || key.is_null() { diff --git a/crates/perry-runtime/src/object/global_this/bigint_promise.rs b/crates/perry-runtime/src/object/global_this/bigint_promise.rs index d03cb60106..43441930fe 100644 --- a/crates/perry-runtime/src/object/global_this/bigint_promise.rs +++ b/crates/perry-runtime/src/object/global_this/bigint_promise.rs @@ -756,7 +756,7 @@ fn require_typed_array_from_of_constructor() { /// non-constructable builtin. fn value_is_constructor(value: f64) -> bool { let bits = value.to_bits(); - if (bits >> 48) == 0x7FFE { + if crate::object::class_value::legacy_class_value_word(bits).is_some() { return true; // class-ref constructor } if crate::proxy::js_proxy_is_proxy(value) == 1 { diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index d761a5ed66..9a7e6500b7 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -84,6 +84,7 @@ mod class_gc_roots; mod class_handles; pub mod class_image; mod class_registry; +pub(crate) mod class_value; #[cfg(test)] mod zeroed_cache_tests; pub(crate) use class_registry::async_resource_prototype_value; @@ -94,6 +95,8 @@ pub(crate) use class_registry::{construct_rooted_arguments, scan_current_new_tar pub(crate) mod accessor_pair; #[cfg(feature = "attr-census")] pub(crate) mod attr_census; +pub(crate) use class_value::class_value_id; +pub use class_value::{class_closure_id, js_class_constructor_called}; pub(crate) mod canonical_keys; mod census; pub(crate) mod key_attrs; diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 055c52795a..09cdbe6bdf 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -714,8 +714,8 @@ pub unsafe extern "C-unwind" fn js_native_call_method_value( if sym_key != 0 { let bits = object.to_bits(); let top16 = bits >> 48; - if top16 == 0x7FFE { - let class_id = (bits & 0xFFFF_FFFF) as u32; + let _ = top16; + if let Some(class_id) = crate::object::class_value::legacy_class_value_word(bits) { let is_prototype_ref = crate::object::class_prototype_ref_id(object).is_some(); if is_prototype_ref { if let Some((func_ptr, param_count, has_rest)) = diff --git a/crates/perry-runtime/src/object/native_call_method/common_methods.rs b/crates/perry-runtime/src/object/native_call_method/common_methods.rs index 164a6806f2..2c45165e71 100644 --- a/crates/perry-runtime/src/object/native_call_method/common_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/common_methods.rs @@ -75,9 +75,10 @@ pub(super) unsafe fn dispatch_common( if crate::symbol::js_is_symbol(key_value) != 0 { return Some(super::object_ops::js_object_has_own(object, key_value)); } - if (object.to_bits() >> 48) == 0x7FFE { + if let Some(class_id) = + crate::object::class_value::legacy_class_value_word(object.to_bits()) + { let key_str = crate::builtins::js_string_coerce(key_value); - let class_id = (object.to_bits() & 0xFFFF_FFFF) as u32; let present = if key_str.is_null() { false } else { diff --git a/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs b/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs index c66c4b9c01..88a08d5448 100644 --- a/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs @@ -34,8 +34,7 @@ pub(super) unsafe fn dispatch_primitive( )); } - if (object.to_bits() >> 48) == 0x7FFE { - let class_id = (object.to_bits() & 0xFFFF_FFFF) as u32; + if let Some(class_id) = crate::object::class_value::legacy_class_value_word(object.to_bits()) { if crate::object::class_prototype_ref_id(object).is_some() { if let Some((func_ptr, param_count, has_synthetic_arguments, has_rest)) = crate::object::class_registry::lookup_class_method_in_chain(class_id, method_name) diff --git a/crates/perry-runtime/src/object/native_module/class_ref_values.rs b/crates/perry-runtime/src/object/native_module/class_ref_values.rs index eb02eed2d3..9fe512a44f 100644 --- a/crates/perry-runtime/src/object/native_module/class_ref_values.rs +++ b/crates/perry-runtime/src/object/native_module/class_ref_values.rs @@ -31,15 +31,11 @@ pub(crate) fn class_prototype_ref_id(value: f64) -> Option { None } +/// A class constructor OR its `C.prototype` reference -> the class id. The +/// constructor half is [`super::class_value::class_value_id`] (both forms); +/// callers that mean only the constructor ask that directly. pub(crate) fn class_ref_id(value: f64) -> Option { - let bits = value.to_bits(); - if (bits >> 48) == 0x7FFE { - let class_id = (bits & 0xFFFF_FFFF) as u32; - if class_id != 0 && is_class_id_registered(class_id) { - return Some(class_id); - } - } - None + super::class_value::class_value_id(value).or_else(|| class_prototype_ref_id(value)) } pub(crate) unsafe fn metadata_key_to_string(value: f64) -> Option { diff --git a/crates/perry-runtime/src/object/object_ops/has_own.rs b/crates/perry-runtime/src/object/object_ops/has_own.rs index 9f6f3712f5..e3effbf465 100644 --- a/crates/perry-runtime/src/object/object_ops/has_own.rs +++ b/crates/perry-runtime/src/object/object_ops/has_own.rs @@ -540,7 +540,7 @@ pub extern "C" fn js_object_property_is_enumerable(obj_value: f64, key_value: f6 // non-enumerable. if crate::symbol::js_is_symbol(key_value) != 0 { let bits = obj_value.to_bits(); - if (bits >> 48) == 0x7FFE { + if crate::object::class_value::legacy_class_value_word(bits).is_some() { // ClassRef receivers: statics live in the class registry and // are non-enumerable like builtin statics. return f64::from_bits(TAG_FALSE); diff --git a/crates/perry-runtime/src/object/object_ops/prototype.rs b/crates/perry-runtime/src/object/object_ops/prototype.rs index 653400db9a..be1cf4fa4f 100644 --- a/crates/perry-runtime/src/object/object_ops/prototype.rs +++ b/crates/perry-runtime/src/object/object_ops/prototype.rs @@ -221,8 +221,7 @@ fn get_prototype_of_resolved(obj_value: f64) -> f64 { let jv = crate::value::JSValue::from_bits(obj_value.to_bits()); // An INT32-tagged value may be a class ref (same 0x7FFE tag as small // integers) — those must keep flowing to the class resolution below. - let is_class_ref = (obj_value.to_bits() >> 48) == 0x7FFE - && super::super::class_ref_id(obj_value).is_some(); + let is_class_ref = super::super::class_ref_id(obj_value).is_some(); let wrapper = if is_class_ref { None } else if jv.is_number() { diff --git a/crates/perry-runtime/src/proxy/apply_construct.rs b/crates/perry-runtime/src/proxy/apply_construct.rs index 866aeb6604..e4e90e2fec 100644 --- a/crates/perry-runtime/src/proxy/apply_construct.rs +++ b/crates/perry-runtime/src/proxy/apply_construct.rs @@ -19,7 +19,7 @@ use crate::closure::js_closure_call3; pub(crate) fn is_callable_function(value: f64) -> bool { let bits = value.to_bits(); // Class-ref constructors (INT32-tagged, top16 == 0x7FFE) are callable. - if (bits >> 48) == 0x7FFE { + if (bits >> 48) == 0x7FFE || crate::object::class_value_id(value).is_some() { return crate::object::class_ref_id(value).is_some(); } // A proxy whose target is callable is itself callable. diff --git a/crates/perry-runtime/src/symbol/get.rs b/crates/perry-runtime/src/symbol/get.rs index 19bc6e9eac..5f1416a449 100644 --- a/crates/perry-runtime/src/symbol/get.rs +++ b/crates/perry-runtime/src/symbol/get.rs @@ -724,8 +724,7 @@ pub(crate) unsafe fn js_object_get_symbol_property_with_receiver( // Check CLASS_STATIC_SYMBOLS first when receiver is a class ref // (top16 == 0x7FFE, INT32_TAG). let bits = obj_f64.to_bits(); - if (bits >> 48) == 0x7FFE { - let class_id = (bits & 0xFFFF_FFFF) as u32; + if let Some(class_id) = crate::object::class_value::legacy_class_value_word(bits) { let sym_key = sym_key_from_f64(sym_f64); if sym_key != 0 { if let Some(v) = diff --git a/crates/perry-runtime/src/symbol/properties.rs b/crates/perry-runtime/src/symbol/properties.rs index 8b323a1b5e..fc8a9ff8d4 100644 --- a/crates/perry-runtime/src/symbol/properties.rs +++ b/crates/perry-runtime/src/symbol/properties.rs @@ -205,8 +205,7 @@ pub(crate) unsafe fn reflect_symbol_getter_closure_bits(obj_f64: f64, sym_f64: f pub(crate) unsafe fn js_object_has_own_symbol_property(obj_f64: f64, sym_f64: f64) -> bool { let bits = obj_f64.to_bits(); - if (bits >> 48) == 0x7FFE { - let class_id = (bits & 0xFFFF_FFFF) as u32; + if let Some(class_id) = crate::object::class_value::legacy_class_value_word(bits) { return class_static_symbol_lookup(class_id, sym_f64).is_some(); } let obj_key = obj_key_from_f64(obj_f64); @@ -393,7 +392,7 @@ unsafe fn set_symbol_property(obj_f64: f64, sym_f64: f64, value_f64: f64) -> f64 // below uses. if !has_own_data && !native_async_resource { let bits = obj_f64.to_bits(); - if (bits >> 48) != 0x7FFE { + if crate::object::class_value::legacy_class_value_word(bits).is_none() { let jsval = crate::value::JSValue::from_bits(bits); if jsval.is_pointer() { let ptr = jsval.as_pointer::(); @@ -447,8 +446,7 @@ unsafe fn set_symbol_property(obj_f64: f64, sym_f64: f64, value_f64: f64) -> f64 } if !has_own_data { let bits = obj_f64.to_bits(); - if (bits >> 48) == 0x7FFE { - let class_id = (bits & 0xFFFF_FFFF) as u32; + if let Some(class_id) = crate::object::class_value::legacy_class_value_word(bits) { if crate::object::class_symbol_setter_apply(class_id, sym_key, obj_f64, value_f64, true) { return value_f64; @@ -676,8 +674,7 @@ pub(crate) fn class_static_symbol_keys_for_class(class_id: u32) -> Vec { #[no_mangle] pub unsafe extern "C" fn js_object_has_own_symbol(obj_f64: f64, sym_f64: f64) -> bool { let bits = obj_f64.to_bits(); - if (bits >> 48) == 0x7FFE { - let class_id = (bits & 0xFFFF_FFFF) as u32; + if let Some(class_id) = crate::object::class_value::legacy_class_value_word(bits) { return class_static_symbol_lookup(class_id, sym_f64).is_some(); } let obj_key = obj_key_from_f64(obj_f64); diff --git a/crates/perry-runtime/src/typed_feedback.rs b/crates/perry-runtime/src/typed_feedback.rs index 112131e90b..0f8e885db4 100644 --- a/crates/perry-runtime/src/typed_feedback.rs +++ b/crates/perry-runtime/src/typed_feedback.rs @@ -2751,7 +2751,7 @@ pub extern "C" fn js_typed_feedback_array_set_string_key( // Class-ref receivers (INT32 tag 0x7FFE) are not arrays; skip the array // shape observation (which would probe the GC header of a non-pointer) and // route straight to the class-ref-aware string-key setter. - if (arr as u64) >> 48 == 0x7FFE { + if crate::object::class_value::legacy_class_ptr_word(arr as u64).is_some() { return crate::array::js_array_set_string_key(arr, key, value); } observe_array(site_id, arr, u32::MAX); diff --git a/crates/perry-runtime/src/value/dyn_index.rs b/crates/perry-runtime/src/value/dyn_index.rs index 41f1b53939..3cf1ffa6a4 100644 --- a/crates/perry-runtime/src/value/dyn_index.rs +++ b/crates/perry-runtime/src/value/dyn_index.rs @@ -225,7 +225,7 @@ pub extern "C" fn js_dyn_index_get(value: f64, index: f64) -> f64 { // CLASS_DYNAMIC_PROPS tables; the computed form must do the same instead of // falling through to the not-a-pointer `undefined` path below. (test262 // class/elements propertyHelper `isWritable(C, "m")` does `C[name] = v`.) - if (bits >> 48) == 0x7FFE { + if crate::object::class_value::legacy_class_value_word(bits).is_some() { let idx_top16 = index.to_bits() >> 48; let key_ptr = if idx_top16 == 0x7FFF || idx_top16 == 0x7FF9 { js_get_string_pointer_unified(index) as *const crate::StringHeader @@ -648,7 +648,7 @@ pub extern "C" fn js_dyn_index_set_strict(obj: f64, index: f64, value: f64, stri // form (`C.key = v`). Without this the write was silently dropped, so // propertyHelper's `isWritable(C, name)` (`C[name] = v`) reported a static // method as non-writable. (Mirrors the get arm above.) - if (bits >> 48) == 0x7FFE { + if crate::object::class_value::legacy_class_value_word(bits).is_some() { let idx_top16 = index.to_bits() >> 48; if idx_top16 == 0x7FFF || idx_top16 == 0x7FF9 { let key_ptr = js_get_string_pointer_unified(index) as *const crate::StringHeader; From a463fd56012d8998f66e3260b9433bc452830081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 26 Sep 2026 13:02:33 +0000 Subject: [PATCH 02/29] fix(runtime,codegen): a class used as a value is its function object (#11414) A class value was the INT32 immediate `0x7FFE_0000_0000_0000 | class_id`, bit-identical to the int32 number equal to its id: `1 === A` was true, a `switch (1) { case A: }` matched, `[A, 1].indexOf(1)` found the class, JSON.stringify printed the id and `A instanceof Object` was false. Each class now has ONE function object per agent (object/class_value.rs): a GC_TYPE_CLOSURE whose code pointer is js_class_constructor_called (its [[Call]], which throws) and whose capture slot 0 holds the class id; minted on first use, born in the old arena and pinned (under a GcSuppressScope, so the lookup never collects), rooted by a per-agent two-level table. `js_class_value(cid)` is its C entry; gc_call_effects classifies it CannotCollect. Producers: the eleven codegen sites that spelled a class as a value (Expr::ClassRef, `ns.C`, imported classes, static `this` in methods and field initializers, the static-field arrow `this` slot, new.target on the inlined and cross-module constructor paths, the static-dispatch receiver) call it; the runtime producers (class_constructor_ref_value, getPrototypeOf's parent, the dynamic parent fallback, static super's target, a class object's `constructor`) return it. Reflect metadata keys fold a class value onto the stable id key. Function.prototype.toString / String() render the class source; util.inspect prints `[class A extends B] { statics }`. C.prototype keeps its current forms; statics stay in their side tables (the next stage moves them into the function object's own-property bag). --- crates/perry-codegen/src/codegen/closure.rs | 4 +- crates/perry-codegen/src/codegen/helpers.rs | 6 +- .../src/codegen/method_static.rs | 5 +- .../src/codegen/static_fields.rs | 3 +- crates/perry-codegen/src/expr/arrays_finds.rs | 3 +- .../perry-codegen/src/expr/dyn_extern_i18n.rs | 3 +- crates/perry-codegen/src/expr/mod.rs | 11 ++ crates/perry-codegen/src/expr/property_get.rs | 3 +- crates/perry-codegen/src/gc_call_effects.rs | 6 +- crates/perry-codegen/src/lower_call/new.rs | 30 ++--- .../property_get/static_dispatch.rs | 11 +- .../src/runtime_decls/strings.rs | 3 + .../perry-codegen/src/stmt/let_scalar_new.rs | 18 ++- .../perry-runtime/src/builtins/formatting.rs | 49 +++++++ .../src/builtins/formatting/value_repr.rs | 2 +- .../src/gc/tests/copying/latch.rs | 10 ++ crates/perry-runtime/src/node_vm.rs | 4 + .../object/class_registry/parent_static.rs | 6 +- .../perry-runtime/src/object/class_value.rs | 121 ++++++++++++++++++ .../field_get_set/class_object_props.rs | 5 +- crates/perry-runtime/src/object/mod.rs | 3 +- .../object/native_module/class_ref_values.rs | 10 +- .../src/object/object_ops/prototype.rs | 6 +- crates/perry-runtime/src/proxy.rs | 2 +- crates/perry-runtime/src/proxy/metadata.rs | 9 +- test-files/test_gap_class_value_identity.ts | 54 ++++++++ test-files/test_gap_class_value_misc.ts | 34 +++++ test-files/test_gap_class_value_reflection.ts | 58 +++++++++ 28 files changed, 417 insertions(+), 62 deletions(-) create mode 100644 test-files/test_gap_class_value_identity.ts create mode 100644 test-files/test_gap_class_value_misc.ts create mode 100644 test-files/test_gap_class_value_reflection.ts diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 83e1a3de40..83898f2847 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -783,9 +783,7 @@ pub(super) fn compile_closure( // closure's synthetic this slot with the enclosing ClassRef rather // than the old 0.0 sentinel so arrows in static fields retain the // class constructor as their SuperProperty receiver. - let class_ref = crate::nanbox::double_literal(f64::from_bits( - crate::nanbox::INT32_TAG | class_id as u64, - )); + let class_ref = crate::expr::emit_class_value(blk, class_id); blk.store(DOUBLE, &class_ref, &slot); } else if entry_bound_this { // A valid non-pointer until the prologue's receiver read below diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 7ac542ae00..499ee3be5e 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -1549,10 +1549,8 @@ pub(super) fn emit_namespace_populator( crate::expr::nanbox_pointer_inline(blk, &handle) } NamespaceEntryKind::LocalClass { class_id } => { - // INT32-tagged class-id NaN-box: 0x7FFE_0000_0000_0000 | - // (class_id & 0xFFFFFFFF). Matches `Expr::ClassRef`. - let bits = crate::nanbox::INT32_TAG | (*class_id as u64 & 0xFFFF_FFFF); - crate::nanbox::double_literal(f64::from_bits(bits)) + // The class's function object, as `Expr::ClassRef` lowers. + crate::expr::emit_class_value(ctx.block(), *class_id) } NamespaceEntryKind::ForeignFunction { source_prefix, diff --git a/crates/perry-codegen/src/codegen/method_static.rs b/crates/perry-codegen/src/codegen/method_static.rs index c04c558683..a4e305f59d 100644 --- a/crates/perry-codegen/src/codegen/method_static.rs +++ b/crates/perry-codegen/src/codegen/method_static.rs @@ -88,12 +88,9 @@ pub(in crate::codegen) fn compile_static_method( // path. (Previously `this` fell through to `js_implicit_this_get` and // read back `undefined`.) let class_ref_cid = class_ids.get(&class.name).copied().unwrap_or(class.id); - let class_ref_lit = { - let bits = crate::nanbox::INT32_TAG | (class_ref_cid as u64 & 0xFFFF_FFFF); - crate::nanbox::double_literal(f64::from_bits(bits)) - }; let (this_slot, locals): (String, HashMap) = { let blk = lf.block_mut(0).unwrap(); + let class_ref_lit = crate::expr::emit_class_value(blk, class_ref_cid); let this_slot = blk.alloca(DOUBLE); // Receiver-sensitive `this`: dynamic dispatch paths (inherited // `D.m()`, `C.m.call(x)` / `.apply(x)`) arm a one-shot override that diff --git a/crates/perry-codegen/src/codegen/static_fields.rs b/crates/perry-codegen/src/codegen/static_fields.rs index 9a79c28d05..f3aea7e441 100644 --- a/crates/perry-codegen/src/codegen/static_fields.rs +++ b/crates/perry-codegen/src/codegen/static_fields.rs @@ -338,8 +338,7 @@ pub(super) fn init_static_fields_late( // class-ref NaN-box a static method binds (see // `compile_static_method`) for the init's duration. let seeded_this = ctx.class_ids.get(&c.name).copied().map(|cid| { - let bits = crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF); - let class_ref_lit = crate::nanbox::double_literal(f64::from_bits(bits)); + let class_ref_lit = crate::expr::emit_class_value(ctx.block(), cid); let this_slot = ctx.func.alloca_entry(DOUBLE); ctx.block().store(DOUBLE, &class_ref_lit, &this_slot); ctx.this_stack.push(this_slot); diff --git a/crates/perry-codegen/src/expr/arrays_finds.rs b/crates/perry-codegen/src/expr/arrays_finds.rs index 6993c12ed7..fee2b2401d 100644 --- a/crates/perry-codegen/src/expr/arrays_finds.rs +++ b/crates/perry-codegen/src/expr/arrays_finds.rs @@ -1449,8 +1449,7 @@ pub(crate) fn lower( // class_ids (legacy callers checking truthiness). Refs #420. Expr::ClassRef(name) => { if let Some(&cid) = ctx.class_ids.get(name) { - let bits = crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF); - Ok(double_literal(f64::from_bits(bits))) + Ok(super::emit_class_value(ctx.block(), cid)) } else { Ok(double_literal(0.0)) } diff --git a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs index 5d25349d74..7b822031c2 100644 --- a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs +++ b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs @@ -888,8 +888,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { if let Some(&cid) = ctx.class_ids.get(name).filter(|_| { !ctx.imported_vars.contains(name) && !ctx.namespace_imports.contains(name) }) { - let bits = crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF); - return Ok(double_literal(f64::from_bits(bits))); + return Ok(super::emit_class_value(ctx.block(), cid)); } // Issue #841: named imports from Node submodules Perry recognizes // as runtime-backed values must win over the generic native-module diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index e2eae9837a..ba3dd3e590 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -13,6 +13,17 @@ use perry_hir::types::Type as HirType; use perry_hir::{BinaryOp, CompareOp, Expr, UnaryOp}; use crate::block::LlBlock; + +/// A class constructor as a VALUE (#11414): the class's per-agent function +/// object. `js_class_value` never collects (`gc_call_effects`) and the object +/// is pinned for the agent's life, so the result needs no root. +pub(crate) fn emit_class_value(blk: &mut LlBlock, class_id: u32) -> String { + blk.call( + DOUBLE, + "js_class_value", + &[(I32, &(class_id as i32).to_string())], + ) +} use crate::codegen::AppMetadata; use crate::collectors::NativeRegionFactGraph; use crate::function::LlFunction; diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index f906cd9769..fae5f74968 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -1140,8 +1140,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { .get(&crate::namespace_member_class_key(name, property)) .copied(); if let Some(cid) = class_cid { - let bits = crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF); - return Ok(double_literal(f64::from_bits(bits))); + return Ok(super::emit_class_value(ctx.block(), cid)); } // Issue #680: prefer the per-namespace map so // `random.make` and `tracer.make` resolve to their diff --git a/crates/perry-codegen/src/gc_call_effects.rs b/crates/perry-codegen/src/gc_call_effects.rs index 99df4be74c..a46d4516a0 100644 --- a/crates/perry-codegen/src/gc_call_effects.rs +++ b/crates/perry-codegen/src/gc_call_effects.rs @@ -338,7 +338,11 @@ pub(crate) fn classify_direct_callee(name: &str) -> GcCallEffect { // raw clear, TLS free-list push) or a TLS pending-map insert. | "js_box_scope_release" | "js_i32_box_scope_release" - | "js_bool_box_scope_release" => GcCallEffect::CannotCollect, + | "js_bool_box_scope_release" + // A class's function object: an indexed per-agent table load; the + // first use allocates it in the old arena under a GcSuppressScope, so + // it never collects and calls no user code. + | "js_class_value" => GcCallEffect::CannotCollect, // Audited allocate-but-never-reenter helpers (2026-07-31): each body // was checked for closure invocation, coercion (valueOf/toString), // and accessor dispatch — none present. The forced-evacuation probe diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index 942918adde..adbb9936f9 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -700,9 +700,7 @@ fn lower_new_impl_inner<'a>( // rewrites — so it goes in a temp root, not a bare register. let saved_new_target = if ctor_chain_uses_new_target(ctx, class) { ctx.class_ids.get(class_name).copied().map(|cid| { - let class_ref = double_literal(f64::from_bits( - crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF), - )); + let class_ref = crate::expr::emit_class_value(ctx.block(), cid); crate::rooting::new_target_save(ctx, &class_ref) }) } else { @@ -1005,17 +1003,13 @@ fn lower_new_impl_inner<'a>( // `INT32_TAG | class_id`, the same value `Expr::ClassRef` produces, so // `new.target === C`, `new.target.name`, and `new.target.prototype` all // work. Falls back to `undefined` if the class id is somehow unresolved. - let new_target_bits = ctx - .class_ids - .get(class_name) - .map(|&cid| crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF)) - .unwrap_or(crate::nanbox::TAG_UNDEFINED); + let new_target_value = match ctx.class_ids.get(class_name).copied() { + Some(cid) => crate::expr::emit_class_value(ctx.block(), cid), + None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), + }; let new_target_slot = ctx.func.alloca_entry(DOUBLE); - ctx.block().store( - DOUBLE, - &double_literal(f64::from_bits(new_target_bits)), - &new_target_slot, - ); + ctx.block() + .store(DOUBLE, &new_target_value, &new_target_slot); ctx.new_target_stack.push(new_target_slot); // Set up the inline-constructor return target. An explicit `return` @@ -1719,7 +1713,10 @@ fn lower_new_impl_inner<'a>( // 'type')`, or silently set `type = undefined` → the auth error // was mis-categorized and the login redirect fell back to // `?error=Configuration`. - let nt_ref = double_literal(f64::from_bits(new_target_bits)); + let nt_ref = match ctx.class_ids.get(class_name).copied() { + Some(cid) => crate::expr::emit_class_value(ctx.block(), cid), + None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), + }; let nt_save = crate::rooting::new_target_save(ctx, &nt_ref); let _ = ctx.block().call(DOUBLE, &ctor.symbol, &ctor_args); crate::rooting::new_target_restore(ctx, &nt_save); @@ -1754,7 +1751,10 @@ fn lower_new_impl_inner<'a>( // new.target cross-module: bind the runtime cell to the leaf // class ref around the imported ctor call (see the ANCESTOR arm // above for why). This is the direct `new ImportedClass()` case. - let nt_ref = double_literal(f64::from_bits(new_target_bits)); + let nt_ref = match ctx.class_ids.get(class_name).copied() { + Some(cid) => crate::expr::emit_class_value(ctx.block(), cid), + None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), + }; let nt_save = crate::rooting::new_target_save(ctx, &nt_ref); let ctor_ret = ctx.block().call(DOUBLE, &ctor.symbol, &ctor_args); crate::rooting::new_target_restore(ctx, &nt_save); diff --git a/crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs b/crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs index d49de12ce3..39f63c5d4b 100644 --- a/crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs +++ b/crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs @@ -134,9 +134,14 @@ pub(crate) fn try_lower_static_dispatch( Expr::LocalGet(_) => lower_expr(ctx, object)?, _ => { // Synthesize a ClassRef NaN-box from the resolved class. - let cid = ctx.class_ids.get(&cls_name).copied().unwrap_or(0); - let bits = crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF); - crate::nanbox::double_literal(f64::from_bits(bits)) + match ctx.class_ids.get(&cls_name).copied() { + Some(cid) if cid != 0 => { + crate::expr::emit_class_value(ctx.block(), cid) + } + _ => crate::nanbox::double_literal(f64::from_bits( + crate::nanbox::TAG_UNDEFINED, + )), + } } }; // `has_rest` unconditionally allocates the synthesized array diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index a6c701ef65..fad0651d62 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -217,6 +217,9 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { // skipping per-evaluation closure allocation on the hot loop. See // `crates/perry-runtime/src/closure.rs::js_closure_alloc_singleton`. module.declare_function("js_closure_alloc_singleton", I64, &[PTR]); + // A class constructor as a value: the class's per-agent function object + // (`object/class_value.rs`; #11414). + module.declare_function("js_class_value", DOUBLE, &[I32]); // Singleton-cached variant for closures with captures, keyed by // `(func_ptr, capture_bits…)`. Args: (func_ptr, capture_count, // captures_ptr — pointer to `capture_count` u64 values). diff --git a/crates/perry-codegen/src/stmt/let_scalar_new.rs b/crates/perry-codegen/src/stmt/let_scalar_new.rs index b4c4c03caf..bc0536bde3 100644 --- a/crates/perry-codegen/src/stmt/let_scalar_new.rs +++ b/crates/perry-codegen/src/stmt/let_scalar_new.rs @@ -171,17 +171,15 @@ pub(super) fn try_lower_scalar_replaced_new( // (`INT32_TAG | class_id`). Without this a `new.target` read in // the ctor (notably `const t = new.target`) fell through to the // runtime cell, which this path never sets, yielding undefined. - let new_target_bits = ctx - .class_ids - .get(class_name) - .map(|&cid| crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF)) - .unwrap_or(crate::nanbox::TAG_UNDEFINED); + let new_target_value = match ctx.class_ids.get(class_name).copied() { + Some(cid) => crate::expr::emit_class_value(ctx.block(), cid), + None => { + crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + } + }; let new_target_slot = ctx.func.alloca_entry(DOUBLE); - ctx.block().store( - DOUBLE, - &crate::nanbox::double_literal(f64::from_bits(new_target_bits)), - &new_target_slot, - ); + ctx.block() + .store(DOUBLE, &new_target_value, &new_target_slot); ctx.new_target_stack.push(new_target_slot); // Stage field initializers around any parent body chain. diff --git a/crates/perry-runtime/src/builtins/formatting.rs b/crates/perry-runtime/src/builtins/formatting.rs index 61dd2d08d6..627f971682 100644 --- a/crates/perry-runtime/src/builtins/formatting.rs +++ b/crates/perry-runtime/src/builtins/formatting.rs @@ -265,6 +265,9 @@ fn format_function_for_console(closure_ptr: *const crate::closure::ClosureHeader if closure_ptr.is_null() { return "[Function (anonymous)]".to_string(); } + if let Some(class_id) = crate::object::class_closure_id(closure_ptr as usize) { + return format_class_for_console(class_id, closure_ptr); + } // Snapshot user-attached own properties and filter out the built-in // function slots that Node hides from `util.inspect`. Node prints @@ -341,6 +344,52 @@ fn format_function_for_console(closure_ptr: *const crate::closure::ClosureHeader format!("{} {{ {} }}", label, parts.join(", ")) } +/// Node's `util.inspect` of a class constructor: `[class A extends B]`, then +/// its enumerable own properties (static fields, runtime-added keys) as +/// `{ k: v }` — the same decoration a plain function gets. Values are read as +/// data (`Object.keys` lists no accessor: class accessors are non-enumerable). +fn format_class_for_console( + class_id: u32, + closure_ptr: *const crate::closure::ClosureHeader, +) -> String { + let label = value_repr::class_label_for_id(class_id); + // The class function object is pinned: `closure_ptr` stays valid across + // the allocations below. + let value = f64::from_bits(crate::value::POINTER_TAG | closure_ptr as u64); + let keys = crate::object::js_object_keys_value(value); + let mut names: Vec = Vec::new(); + if !keys.is_null() { + for i in 0..crate::array::js_array_length(keys) { + if let Some(name) = jsvalue_string_content(crate::array::js_array_get_f64(keys, i)) { + names.push(name); + } + } + } + if names.is_empty() { + return label; + } + let mut parts: Vec = Vec::with_capacity(names.len()); + for name in names { + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let v = crate::object::js_object_get_field_by_name_f64( + closure_ptr as *const crate::object::ObjectHeader, + key, + ); + let rendered = format_jsvalue(v, 1); + let rendered = if crate::value::JSValue::from_bits(v.to_bits()).is_any_string() { + format!("'{rendered}'") + } else { + rendered + }; + parts.push(format!( + "{}: {}", + format_inspect_property_key(&name), + rendered + )); + } + format!("{} {{ {} }}", label, parts.join(", ")) +} + // Per-thread override for the `showHidden` inspect option. Defaults to // `false` (Node default): `util.inspect` / `console.log` only show // enumerable properties. `console.dir(value, { showHidden: true })` diff --git a/crates/perry-runtime/src/builtins/formatting/value_repr.rs b/crates/perry-runtime/src/builtins/formatting/value_repr.rs index 0df500db9f..f39a153224 100644 --- a/crates/perry-runtime/src/builtins/formatting/value_repr.rs +++ b/crates/perry-runtime/src/builtins/formatting/value_repr.rs @@ -50,7 +50,7 @@ pub(crate) fn class_ref_inspect_label(value: f64) -> Option { } /// `[class …]` for a resolved class id. -fn class_label_for_id(class_id: u32) -> String { +pub(crate) fn class_label_for_id(class_id: u32) -> String { // The name comes from whatever `class_name_for_id` reports — deliberately // NOT re-derived here. #9413 covers class `.name` leaking compiler-internal // spellings; when that lands, the corrected name flows straight through. diff --git a/crates/perry-runtime/src/gc/tests/copying/latch.rs b/crates/perry-runtime/src/gc/tests/copying/latch.rs index 88b464e752..22d5923c8d 100644 --- a/crates/perry-runtime/src/gc/tests/copying/latch.rs +++ b/crates/perry-runtime/src/gc/tests/copying/latch.rs @@ -330,6 +330,16 @@ fn pin_object_non_young_call_sites_are_never_young() { ever young, pin_object_non_young there would be memory corruption" ); + // `object/class_value.rs` pins each class function object, which it + // allocates born-tenured in the OLD arena. + let class_fn = crate::object::class_value::class_value_ptr(0x7A11); + let cf_header = header_from_user_ptr(class_fn as *const u8) as *mut GcHeader; + assert!( + !crate::gc::pin::pin_constrains_copying_minor_for_tests(cf_header), + "a class function object is born in the old arena; if it were ever \ + young, pin_object_non_young there would be memory corruption" + ); + // Control: a plain nursery object IS young, so the predicate the two // assertions above rely on is not vacuously false for everything. let young = young_leaf(); diff --git a/crates/perry-runtime/src/node_vm.rs b/crates/perry-runtime/src/node_vm.rs index c5f871b535..5890405711 100644 --- a/crates/perry-runtime/src/node_vm.rs +++ b/crates/perry-runtime/src/node_vm.rs @@ -229,6 +229,10 @@ pub(crate) fn compiled_function_source_for_closure(closure: usize) -> Option String { + // A class function object renders its class's retained source. + if let Some(class_id) = crate::object::class_closure_id(closure) { + return crate::object::class_ref_to_string(class_id).into_owned(); + } compiled_function_source_for_closure(closure).unwrap_or_else(|| { let closure_ptr = closure as *const ClosureHeader; let func_ptr = unsafe { 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 f0ea5480c0..b6fcc3ddd5 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -91,6 +91,9 @@ pub(crate) fn dynamic_value_class_id(value: f64) -> u32 { _ => 0, } } + } else if let Some(class_id) = crate::object::class_value::class_value_id_bits(bits) { + // A class function object names its class. + class_id } else if tag == POINTER_TAG { // Object instance: read class_id from the ObjectHeader. let ptr = crate::value::js_nanbox_get_pointer(value) as *const ObjectHeader; @@ -500,7 +503,6 @@ pub extern "C" fn js_get_dynamic_parent_value(class_id: u32) -> f64 { /// the constructor currently running inherits. pub(crate) fn template_dynamic_parent_value(class_id: u32) -> f64 { const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; - const INT32_TAG: u64 = 0x7FFE_0000_0000_0000; if class_id == 0 { return f64::from_bits(TAG_UNDEFINED); } @@ -524,7 +526,7 @@ pub(crate) fn template_dynamic_parent_value(class_id: u32) -> f64 { // snapshot caps by the signature split. if let Some(parent_cid) = crate::object::get_parent_class_id(class_id) { if parent_cid != 0 { - return f64::from_bits(INT32_TAG | parent_cid as u64); + return crate::object::class_value::class_value(parent_cid); } } f64::from_bits(TAG_UNDEFINED) diff --git a/crates/perry-runtime/src/object/class_value.rs b/crates/perry-runtime/src/object/class_value.rs index f49dc4e829..8eff078bec 100644 --- a/crates/perry-runtime/src/object/class_value.rs +++ b/crates/perry-runtime/src/object/class_value.rs @@ -111,3 +111,124 @@ pub(crate) fn boxed_class_word(bits: u64) -> f64 { f64::from_bits(bits) } } + +// --------------------------------------------------------------------------- +// The function object for each class: one per agent per class id. +// --------------------------------------------------------------------------- + +/// Class ids per table page (log2). Class ids are dense per program plus a few +/// high reserved ids for built-in classes, so a two-level table keeps the +/// lookup a pair of indexed loads without a large flat array. +const CLASS_VALUE_PAGE_SHIFT: u32 = 8; +const CLASS_VALUE_PAGE_LEN: usize = 1 << CLASS_VALUE_PAGE_SHIFT; + +type ClassValuePage = Box<[*mut ClosureHeader; CLASS_VALUE_PAGE_LEN]>; + +crate::perry_thread_local! { + /// This agent's class function objects, indexed by class id. A GC root + /// (rewritten on a move) via [`scan_class_value_roots_mut`]. + static CLASS_VALUES: std::cell::RefCell>> = + const { std::cell::RefCell::new(Vec::new()) }; +} + +#[inline] +fn class_value_cached(class_id: u32) -> Option<*mut ClosureHeader> { + let page = (class_id >> CLASS_VALUE_PAGE_SHIFT) as usize; + let index = class_id as usize & (CLASS_VALUE_PAGE_LEN - 1); + CLASS_VALUES.with(|t| { + let t = t.borrow(); + let p = t.get(page)?.as_ref()?; + let c = p[index]; + (!c.is_null()).then_some(c) + }) +} + +/// Allocate the class function object for `class_id`: a closure born in the +/// old generation and pinned (it lives as long as the agent and never moves), +/// code pointer +/// [`js_class_constructor_called`], capture slot 0 = the class id as INT32. +/// +/// Never collects: callers hold raw receiver pointers across the lookup, so +/// the old-arena allocation runs under a [`crate::gc::GcSuppressScope`]. +#[cold] +#[inline(never)] +fn class_value_mint(class_id: u32) -> *mut ClosureHeader { + let _no_collect = crate::gc::GcSuppressScope::new(); + let payload = crate::closure::closure_payload_size(1); + let ptr = crate::arena::arena_alloc_gc_old_born_tenured( + payload, + std::mem::align_of::(), + crate::gc::GC_TYPE_CLOSURE, + ) as *mut ClosureHeader; + unsafe { + // GC_STORE_AUDIT(INIT): fresh class function object; the one capture + // is an INT32 class id and the props edge is null — pointer-free. + (*ptr).capture_count = 1; + (*ptr).shape_id = crate::closure::shape::function_dictionary_shape(); + (*ptr).func_ptr = js_class_constructor_called as *const u8; + (*ptr).props = std::ptr::null_mut(); + std::ptr::write( + crate::closure::closure_capture_slots_mut(ptr), + crate::value::INT32_TAG | class_id as u64, + ); + crate::gc::layout_init_pointer_free(ptr as *mut u8); + // Born old AND pinned: the address is the class's identity for the + // agent's life (compiled code keeps it in registers and allocas, the + // metadata and weak tables compare it), so no collector may move it. + crate::gc::pin_object_non_young( + (ptr as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader + ); + } + let page = (class_id >> CLASS_VALUE_PAGE_SHIFT) as usize; + let index = class_id as usize & (CLASS_VALUE_PAGE_LEN - 1); + CLASS_VALUES.with(|t| { + let mut t = t.borrow_mut(); + if t.len() <= page { + t.resize_with(page + 1, || None); + } + t[page].get_or_insert_with(|| Box::new([std::ptr::null_mut(); CLASS_VALUE_PAGE_LEN])) + [index] = ptr; + }); + crate::gc::runtime_write_barrier_root_heap_word(ptr as u64); + ptr +} + +/// The class function object for `class_id` on this agent (minted on first +/// use). `class_id` must be a registered class. +#[inline] +pub(crate) fn class_value_ptr(class_id: u32) -> *mut ClosureHeader { + match class_value_cached(class_id) { + Some(c) => c, + None => class_value_mint(class_id), + } +} + +/// The VALUE of class `class_id`'s constructor: its function object, NaN-boxed. +#[inline] +pub(crate) fn class_value(class_id: u32) -> f64 { + f64::from_bits(crate::value::POINTER_TAG | (class_value_ptr(class_id) as u64)) +} + +/// Emitted for every `Expr::ClassRef` and every place compiled code names a +/// class as a value (static `this`, `new.target`, `ns.C`): the class's +/// function object. A per-agent indexed load; never allocates after the +/// first use and never collects. +#[no_mangle] +pub extern "C" fn js_class_value(class_id: i32) -> f64 { + class_value(class_id as u32) +} + +/// GC root scan for [`CLASS_VALUES`]; registered from +/// `object::scan_object_cache_roots_mut`. +pub(crate) fn scan_class_value_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { + CLASS_VALUES.with(|t| { + let mut t = t.borrow_mut(); + for page in t.iter_mut().flatten() { + for slot in page.iter_mut() { + if !slot.is_null() { + visitor.visit_raw_mut_ptr_slot(slot); + } + } + } + }); +} diff --git a/crates/perry-runtime/src/object/field_get_set/class_object_props.rs b/crates/perry-runtime/src/object/field_get_set/class_object_props.rs index 9cf0a88e46..f913510351 100644 --- a/crates/perry-runtime/src/object/field_get_set/class_object_props.rs +++ b/crates/perry-runtime/src/object/field_get_set/class_object_props.rs @@ -383,8 +383,9 @@ pub(super) unsafe fn instance_constructor_value( ); } if class_id != 0 && is_class_id_registered(class_id) { - let bits = 0x7FFE_0000_0000_0000u64 | (class_id as u64); - return Some(JSValue::from_bits(bits)); + return Some(JSValue::from_bits( + crate::object::class_value::class_value(class_id).to_bits(), + )); } // class_id == 0 fallback: plain ObjectHeader allocated // without an HIR shape (Object.create(null) hybrids, raw diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 9a7e6500b7..cdcfd1e1f9 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -96,7 +96,7 @@ pub(crate) mod accessor_pair; #[cfg(feature = "attr-census")] pub(crate) mod attr_census; pub(crate) use class_value::class_value_id; -pub use class_value::{class_closure_id, js_class_constructor_called}; +pub use class_value::{class_closure_id, js_class_constructor_called, js_class_value}; pub(crate) mod canonical_keys; mod census; pub(crate) mod key_attrs; @@ -1557,6 +1557,7 @@ pub fn scan_object_cache_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<' // holding it is a real GC root that a moving collection must rewrite. null_stub::scan_null_stub_roots_mut(visitor); crate::closure::shape::scan_function_prototype_roots_mut(visitor); + class_value::scan_class_value_roots_mut(visitor); #[cfg(feature = "regex-engine")] regex_proto_thunks::scan_canonical_test_site_roots_mut(visitor); } diff --git a/crates/perry-runtime/src/object/native_module/class_ref_values.rs b/crates/perry-runtime/src/object/native_module/class_ref_values.rs index 9fe512a44f..fc08b0d8c5 100644 --- a/crates/perry-runtime/src/object/native_module/class_ref_values.rs +++ b/crates/perry-runtime/src/object/native_module/class_ref_values.rs @@ -10,8 +10,16 @@ // away from the top of a module. pub(crate) const CLASS_PROTOTYPE_REF_FLAG: u64 = 1u64 << 32; +/// The VALUE of class `class_id`'s constructor: its function object. pub(crate) fn class_constructor_ref_value(class_id: u32) -> f64 { - f64::from_bits(0x7FFE_0000_0000_0000u64 | (class_id as u64 & 0xFFFF_FFFF)) + super::class_value::class_value(class_id) +} + +/// A stable, non-moving KEY for class `class_id`'s constructor, for side +/// tables that key by value bits (the legacy immediate's bits; never a value +/// handed to user code). +pub(crate) fn class_constructor_key_bits(class_id: u32) -> u64 { + 0x7FFE_0000_0000_0000u64 | (class_id as u64 & 0xFFFF_FFFF) } pub(crate) fn class_prototype_ref_value(class_id: u32) -> f64 { diff --git a/crates/perry-runtime/src/object/object_ops/prototype.rs b/crates/perry-runtime/src/object/object_ops/prototype.rs index be1cf4fa4f..baf5a2f05c 100644 --- a/crates/perry-runtime/src/object/object_ops/prototype.rs +++ b/crates/perry-runtime/src/object/object_ops/prototype.rs @@ -406,8 +406,7 @@ fn get_prototype_of_resolved(obj_value: f64) -> f64 { f64::from_bits(TAG_NULL) } }; - if top16 == 0x7FFE { - let class_id = (bits & 0xFFFF_FFFF) as u32; + if let Some(class_id) = crate::object::class_value::legacy_class_value_word(bits) { // An explicit `Object.setPrototypeOf(Ctor, obj)` wins over every // derived answer below — it IS the constructor's [[Prototype]]. if super::super::class_prototype_ref_id(obj_value).is_none() { @@ -443,8 +442,7 @@ fn get_prototype_of_resolved(obj_value: f64) -> f64 { // %Object.prototype%, so the synthetic class whose proto was that // namespace inherits Object.prototype too. if parent_id != 0 && parent_id != super::super::native_module::NATIVE_MODULE_CLASS_ID { - let parent_bits = 0x7FFE_0000_0000_0000u64 | (parent_id as u64); - return f64::from_bits(parent_bits); + return crate::object::class_value::class_value(parent_id); } } // Root of the class hierarchy. In JS `Object.getPrototypeOf` of a base diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index c933074964..555e8c7f57 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -2433,7 +2433,7 @@ pub extern "C" fn js_super_put_value_set( // path looked at `Parent.prototype` and made valid static writes fail. if let Some(child_id) = crate::object::class_ref_id(receiver) { let target = if parent_class_id != 0 { - f64::from_bits(crate::value::INT32_TAG | parent_class_id as u64) + crate::object::class_value::class_value(parent_class_id) } else { crate::object::js_get_dynamic_parent_value(child_id) }; diff --git a/crates/perry-runtime/src/proxy/metadata.rs b/crates/perry-runtime/src/proxy/metadata.rs index 3f2182416d..daca302dab 100644 --- a/crates/perry-runtime/src/proxy/metadata.rs +++ b/crates/perry-runtime/src/proxy/metadata.rs @@ -120,14 +120,19 @@ pub extern "C" fn js_reflect_delete_metadata(key: f64, target: f64, property_key fn normalize_target_bits(target: f64) -> u64 { // Synthetic class-prototype ref → fold onto the class constructor key. if let Some(cid) = crate::object::class_prototype_ref_id(target) { - return crate::object::class_constructor_ref_value(cid).to_bits(); + return crate::object::class_constructor_key_bits(cid); + } + // A class constructor (either form) -> its stable key: the function object + // is a heap cell whose address is not a durable key. + if let Some(cid) = crate::object::class_value_id(target) { + return crate::object::class_constructor_key_bits(cid); } // Live decl-prototype heap object → fold onto the class constructor key. let bits = target.to_bits(); if (bits >> 48) == (POINTER_TAG >> 48) { let ptr = (bits & POINTER_MASK) as usize; if let Some(cid) = crate::object::class_id_for_decl_prototype_object(ptr) { - return crate::object::class_constructor_ref_value(cid).to_bits(); + return crate::object::class_constructor_key_bits(cid); } } bits diff --git a/test-files/test_gap_class_value_identity.ts b/test-files/test_gap_class_value_identity.ts new file mode 100644 index 0000000000..2198ac9f66 --- /dev/null +++ b/test-files/test_gap_class_value_identity.ts @@ -0,0 +1,54 @@ +// #11414: a class used as a value must be a real function object, never +// bit-identical to a number. +class A { + static s = 7; + x = 1; + static make() { return new this(); } +} +class B extends A { + static t = 9; +} +const one: any = 1; +const two: any = 2; +const vals: any[] = [0, 1, 2, 3, 4, 5, 6, 7, 8]; +let eq = 0; +for (const v of vals) { if ((v as any) === (A as any)) eq++; if ((v as any) === (B as any)) eq++; } +console.log("num===class", eq); +console.log("typeof", typeof A, typeof B, typeof one, typeof two); +const m = new Map(); +m.set(A, "A"); m.set(B, "B"); +console.log("map", m.get(1), m.get(2), m.get(A), m.get(B), m.size); +m.set(1, "one"); +console.log("map2", m.get(A), m.get(1), m.size); +const wm = new WeakMap(); +wm.set(A, "wa"); +console.log("weakmap", wm.get(A), wm.has(B)); +let threw = "no"; +try { wm.set(one, "x"); } catch (e) { threw = "yes"; } +console.log("weakmap int key throws", threw); +const s = new Set([A, B, 1, 2]); +console.log("set size", s.size); +console.log("print", String(A).startsWith("class A"), `${B}`.startsWith("class B")); +console.log("name", A.name, B.name, A.length); +console.log("instanceof", new B() instanceof A, new A() instanceof B, (A as any) instanceof Function, (A as any) instanceof Object); +console.log("static inh", (B as any).s, B.t, Object.getPrototypeOf(B) === A); +console.log("static this", (B.make() as any) instanceof B); +console.log("arith", (A as any) + 1 === 2, typeof ((A as any) + 1), Number(A as any)); +console.log("json", JSON.stringify({ a: A }), JSON.stringify([A])); +console.log("proto ne num", (A.prototype as any) === (4294967297 as any), typeof A.prototype); +class NT { t: any; constructor() { this.t = new.target; } } +class NT2 extends NT {} +const nt: any = new NT2(); +console.log("new.target", nt.t === NT2, typeof nt.t, nt.t === 0, nt.t.name); +function mk(n: number) { return class K { v = n; static tag = n; }; } +const K1 = mk(1), K2 = mk(2); +console.log("expr twice", K1 === K2, K1.tag, K2.tag, new K1().v, new K2().v, new K1() instanceof K2, typeof K1); +const mk2 = new Map(); mk2.set(K1, 1); mk2.set(K2, 2); +console.log("expr map", mk2.size, mk2.get(K1), mk2.get(K2)); +const arr: any[] = [A, 1]; +console.log("indexOf", arr.indexOf(1), arr.indexOf(A), arr.includes(A)); +console.log("obj key", Object.is(A, 1), Object.is(A, A)); +let callThrew = "no"; +try { (A as any)(); } catch (e) { callThrew = (e as any) instanceof TypeError ? "TypeError" : "other"; } +console.log("call throws", callThrew); +console.log("switch", (() => { switch (one as any) { case A: return "A"; default: return "num"; } })()); diff --git a/test-files/test_gap_class_value_misc.ts b/test-files/test_gap_class_value_misc.ts new file mode 100644 index 0000000000..f66b7c0b64 --- /dev/null +++ b/test-files/test_gap_class_value_misc.ts @@ -0,0 +1,34 @@ +// Class constructors as function objects: printing, statics blocks, private +// statics, super in statics, metadata-free reflection, collections. +class A { static #count = 0; static inc() { return ++A.#count; } static { (A as any).boot = "booted"; } } +class B extends A { static who() { return super.inc() * 10; } } +console.log(A, B, [A, 1], { k: B }); +console.log(A.inc(), B.who(), (A as any).boot, (B as any).boot); +class H { static [Symbol.hasInstance](v: any) { return v === 42; } } +console.log((42 as any) instanceof H, ({} as any) instanceof H); +const byClass = new Map(); +for (const C of [A, B, A, H]) byClass.set(C, (byClass.get(C) ?? 0) + 1); +console.log([...byClass.values()].join(","), byClass.has(A), byClass.has(1 as any)); +const ws = new WeakSet([A, B]); +console.log(ws.has(A), ws.has(H)); +const wr = new WeakRef(A); +console.log(wr.deref() === A); +const obj: Record = {}; +obj[A.name] = A; +console.log(obj.A === A, typeof obj.A); +const arr = [H, B, A]; +arr.sort((x: any, y: any) => x.name.localeCompare(y.name)); +console.log(arr.map((c) => c.name).join(",")); +console.log([A, B].indexOf(B), [1, 2, 3].indexOf(A as any), [A].lastIndexOf(A)); +function takesCtor(c: new () => object) { return new c(); } +console.log(takesCtor(B) instanceof A); +class P { v: string; constructor() { this.v = new.target.name; } } +class Q extends P {} +console.log(new P().v, new Q().v); +const bound = (B as any).who.bind(B); +console.log(bound()); +let n: any = A; +n = n === A ? "same" : "diff"; +console.log(n, (A as any) == (A as any), (A as any) === (B as any)); +console.log(Number.isInteger(A as any), Array.isArray(A), typeof (A as any).prototype); +console.log(String([A]).startsWith("class A"), `${[B]}`.includes("extends A")); diff --git a/test-files/test_gap_class_value_reflection.ts b/test-files/test_gap_class_value_reflection.ts new file mode 100644 index 0000000000..5735c24f6c --- /dev/null +++ b/test-files/test_gap_class_value_reflection.ts @@ -0,0 +1,58 @@ +// Class constructors as function objects: reflection, statics, dynamic new, +// class expressions evaluated more than once. +class A { + static s = 7; + static get g() { return "g" + this.s; } + static m() { return this.name; } + x = 1; + hi() { return "hi" + this.x; } +} +class B extends A { static t = 9; } +const a: any = A, b: any = B; +console.log("ctor", A.prototype.constructor === A, Object.getPrototypeOf(new A()) === A.prototype); +console.log("proto chain", Object.getPrototypeOf(B) === A, Object.getPrototypeOf(A) === Function.prototype, + Object.getPrototypeOf(B.prototype) === A.prototype); +console.log("own", a.hasOwnProperty("s"), b.hasOwnProperty("s"), b.hasOwnProperty("t"), "s" in b, "prototype" in a); +console.log("keys", Object.keys(A).join(","), Object.keys(B).join(",")); +console.log("names", Object.getOwnPropertyNames(A).sort().join(",")); +console.log("statics", B.m(), B.g, a.g, A.m()); +a.s = 8; +console.log("static write", A.s, b.s, B.g); +b.s = 5; +console.log("shadow", A.s, B.s, b.hasOwnProperty("s")); +a.dyn = "d"; +console.log("dyn", a.dyn, b.dyn, Object.keys(A).includes("dyn")); +delete a.dyn; +console.log("deleted", a.dyn, "dyn" in a); +Object.defineProperty(A, "ro", { value: 3, writable: false, enumerable: false }); +console.log("define", a.ro, Object.keys(A).includes("ro"), Object.getOwnPropertyDescriptor(A, "ro")!.writable); +const d = Object.getOwnPropertyDescriptor(A, "prototype")!; +console.log("proto desc", d.writable, d.enumerable, d.configurable); +function mk(c: any, ...args: any[]) { return new c(...args); } +console.log("dyn new", mk(A).hi(), mk(B) instanceof A, mk(B).x); +console.log("reflect", (Reflect.construct(A, []) as any).hi(), Reflect.construct(A, [], B) instanceof B); +const Bound: any = (A as any).bind(null); +console.log("bound", new Bound() instanceof A, typeof Bound); +let t = "no"; try { (A as any).call({}); } catch (e) { t = (e as any).constructor.name; } +console.log("call", t); +console.log("fnproto", typeof (A as any).call, typeof (A as any).apply, (A as any).call === Function.prototype.call, (A as any).bind === Function.prototype.bind); +const list: any[] = []; +for (let i = 0; i < 3; i++) { + const C = class { static n = i; v = i * 10; static who() { return this.n; } }; + list.push(C); +} +console.log("expr", list[0] === list[1], list.map((c) => c.n).join(","), list.map((c) => new c().v).join(","), + list.map((c) => c.who()).join(","), new list[1]() instanceof list[1], new list[1]() instanceof list[2]); +const s = new Set(list); +console.log("expr set", s.size, list.map((c) => typeof c).join(",")); +class Base { static create(this: any) { return new this(); } kind() { return "base"; } } +class Derived extends Base { kind() { return "derived"; } } +console.log("static this ctor", (Derived.create() as any).kind(), (Base.create() as any).kind()); +const reg = new Map([[A, "a"], [B, "b"]]); +for (const [k, v] of reg) console.log("iter", k === A ? "A" : k === B ? "B" : "?", v, typeof k); +const wm = new WeakMap([[A, 1]]); +console.log("wm", wm.get(A), wm.get(B), wm.has(A)); +console.log("eq", (A as any) == 1, (A as any) == (A as any), (A as any) !== (B as any), [A].includes(1 as any)); +console.log("num", isNaN(A as any), typeof (+(A as any)), (A as any) < 5, (A as any) > 0); +console.log("str", String(B).length > 0, Object.prototype.toString.call(A)); +console.log("obj", Object(A) === A, typeof Object(A)); From 4fbe113ad2d054cd1db3a1b16f090c8c65edbe67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 26 Sep 2026 13:37:17 +0000 Subject: [PATCH 03/29] perf(codegen,runtime): class values cost a cached load; gates for the class function object - Each site that names a class as a value caches the pinned function object in a zero-initialised per-site global (thread-local when the program starts workers): a load and a never-taken branch after the first use, instead of a per-agent table lookup (`(i & 1) ? A : B` + compare: 281 -> 34 instr/op; base 24.5). - Strict identity against `Expr::ClassRef` is bit identity (the object is one per class and never moves), like proven symbols: no js_jsvalue_equals. - A static method's prologue resolves `this` with js_static_this_resolve_class (cid) instead of materializing the class value on every call. - `new C()` on a class value decides "class" first (one closure probe) instead of after the exotic-constructor arms (dynamic new 11218 -> 6713; base 6545). - Gates: object/mod.rs back to 2000 lines (the scanner registers in gc::mod; callers name object::class_value::* directly), pin through pin_user_ptr_non_young and the header read through addr_class, metadata tests pinned to the stable key, three class_value unit tests, the latch test covers the new pin site, changelog fragment. --- ...11414-class-values-are-function-objects.md | 9 ++ crates/perry-codegen/src/codegen/helpers.rs | 2 +- .../src/codegen/method_static.rs | 7 +- .../src/codegen/static_fields.rs | 2 +- crates/perry-codegen/src/expr/arrays_finds.rs | 2 +- crates/perry-codegen/src/expr/compare.rs | 15 +++- .../perry-codegen/src/expr/dyn_extern_i18n.rs | 2 +- crates/perry-codegen/src/expr/mod.rs | 34 +++++++ crates/perry-codegen/src/expr/property_get.rs | 2 +- crates/perry-codegen/src/lower_call/new.rs | 8 +- .../property_get/static_dispatch.rs | 4 +- .../runtime_decls/stdlib_ffi/language_core.rs | 1 + .../perry-codegen/src/stmt/let_scalar_new.rs | 2 +- .../perry-runtime/src/builtins/formatting.rs | 2 +- crates/perry-runtime/src/gc/mod.rs | 2 + crates/perry-runtime/src/node_vm.rs | 2 +- .../src/object/class_registry/construct.rs | 9 +- .../perry-runtime/src/object/class_value.rs | 90 +++++++++++++++++-- crates/perry-runtime/src/object/mod.rs | 5 +- .../perry-runtime/src/object/this_binding.rs | 23 +++++ .../src/proxy/apply_construct.rs | 2 +- crates/perry-runtime/src/proxy/metadata.rs | 9 +- 22 files changed, 199 insertions(+), 35 deletions(-) create mode 100644 changelog.d/11414-class-values-are-function-objects.md diff --git a/changelog.d/11414-class-values-are-function-objects.md b/changelog.d/11414-class-values-are-function-objects.md new file mode 100644 index 0000000000..5e7c440552 --- /dev/null +++ b/changelog.d/11414-class-values-are-function-objects.md @@ -0,0 +1,9 @@ +### Fixed + +A class used as a value is now a real function object. It used to be encoded +as the int32 number equal to its internal class id, so `1 === SomeClass` could +be true, `switch (1) { case SomeClass: }` matched, `[SomeClass, 1].indexOf(1)` +found the class, `JSON.stringify({ c: SomeClass })` printed the id and +`SomeClass instanceof Object` was `false`. Each class now has one function +object per agent, which compares, hashes, prints and reflects as it does in +Node (`[class A extends B] { statics }` in `util.inspect`). diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 499ee3be5e..d25c208976 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -1550,7 +1550,7 @@ pub(super) fn emit_namespace_populator( } NamespaceEntryKind::LocalClass { class_id } => { // The class's function object, as `Expr::ClassRef` lowers. - crate::expr::emit_class_value(ctx.block(), *class_id) + crate::expr::emit_class_value_cached(ctx, *class_id) } NamespaceEntryKind::ForeignFunction { source_prefix, diff --git a/crates/perry-codegen/src/codegen/method_static.rs b/crates/perry-codegen/src/codegen/method_static.rs index a4e305f59d..077dcebf63 100644 --- a/crates/perry-codegen/src/codegen/method_static.rs +++ b/crates/perry-codegen/src/codegen/method_static.rs @@ -46,7 +46,7 @@ pub(in crate::codegen) fn compile_static_method( // gh #6206 / #6081: same shadow-frame emission as compile_method — static // method bodies were equally invisible to the exact-roots copying minor. // One extra slot roots the resolved receiver: static `this` is usually - // the non-pointer INT32 class-ref, but `js_static_this_resolve` returns a + // the class's pinned function object, but `js_static_this_resolve_class` returns a // REAL heap receiver for `C.m.call(x)` / `.apply(x)` / inherited `D.m()` // dynamic dispatch, and that object may be reachable only from this slot. // #10663: decided before any statement is lowered. @@ -90,7 +90,6 @@ pub(in crate::codegen) fn compile_static_method( let class_ref_cid = class_ids.get(&class.name).copied().unwrap_or(class.id); let (this_slot, locals): (String, HashMap) = { let blk = lf.block_mut(0).unwrap(); - let class_ref_lit = crate::expr::emit_class_value(blk, class_ref_cid); let this_slot = blk.alloca(DOUBLE); // Receiver-sensitive `this`: dynamic dispatch paths (inherited // `D.m()`, `C.m.call(x)` / `.apply(x)`) arm a one-shot override that @@ -100,8 +99,8 @@ pub(in crate::codegen) fn compile_static_method( // real receiver (test262 class/elements static-private-*). let resolved_this = blk.call( DOUBLE, - "js_static_this_resolve", - &[(DOUBLE, &class_ref_lit)], + "js_static_this_resolve_class", + &[(I32, &(class_ref_cid as i32).to_string())], ); blk.store(DOUBLE, &resolved_this, &this_slot); if crate::codegen::helpers::precise_root_analysis_enabled() { diff --git a/crates/perry-codegen/src/codegen/static_fields.rs b/crates/perry-codegen/src/codegen/static_fields.rs index f3aea7e441..ec187172d2 100644 --- a/crates/perry-codegen/src/codegen/static_fields.rs +++ b/crates/perry-codegen/src/codegen/static_fields.rs @@ -338,7 +338,7 @@ pub(super) fn init_static_fields_late( // class-ref NaN-box a static method binds (see // `compile_static_method`) for the init's duration. let seeded_this = ctx.class_ids.get(&c.name).copied().map(|cid| { - let class_ref_lit = crate::expr::emit_class_value(ctx.block(), cid); + let class_ref_lit = crate::expr::emit_class_value_cached(ctx, cid); let this_slot = ctx.func.alloca_entry(DOUBLE); ctx.block().store(DOUBLE, &class_ref_lit, &this_slot); ctx.this_stack.push(this_slot); diff --git a/crates/perry-codegen/src/expr/arrays_finds.rs b/crates/perry-codegen/src/expr/arrays_finds.rs index fee2b2401d..af5b5492c3 100644 --- a/crates/perry-codegen/src/expr/arrays_finds.rs +++ b/crates/perry-codegen/src/expr/arrays_finds.rs @@ -1449,7 +1449,7 @@ pub(crate) fn lower( // class_ids (legacy callers checking truthiness). Refs #420. Expr::ClassRef(name) => { if let Some(&cid) = ctx.class_ids.get(name) { - Ok(super::emit_class_value(ctx.block(), cid)) + Ok(super::emit_class_value_cached(ctx, cid)) } else { Ok(double_literal(0.0)) } diff --git a/crates/perry-codegen/src/expr/compare.rs b/crates/perry-codegen/src/expr/compare.rs index 5f2bfc6ca6..40b9eef35f 100644 --- a/crates/perry-codegen/src/expr/compare.rs +++ b/crates/perry-codegen/src/expr/compare.rs @@ -71,6 +71,12 @@ fn typeof_literal_pair<'a>( /// storage (reclaimable but non-moving), while `Symbol.for()` values are /// process-lifetime `Box` allocations. Therefore a proven Symbol can equal /// another JS value iff their NaN-boxed pointer bits are identical. +/// A declared class named as a value (`Expr::ClassRef`): its pinned function +/// object (`js_class_value`). +fn is_class_value_expr(ctx: &FnCtx<'_>, expr: &Expr) -> bool { + matches!(expr, Expr::ClassRef(name) if ctx.class_ids.contains_key(name)) +} + pub(crate) fn is_proven_symbol_expr(ctx: &FnCtx<'_>, expr: &Expr) -> bool { match expr { Expr::SymbolNew(_) | Expr::SymbolFor(_) => true, @@ -1252,7 +1258,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // STRICT only: loose equality still has coercion/throw rules. let either_proven_symbol = is_proven_symbol_expr(ctx, left) || is_proven_symbol_expr(ctx, right); - if either_proven_symbol && matches!(op, CompareOp::Eq | CompareOp::Ne) { + // A class value is its class's pinned function object — one + // per class, never moved or forwarded — so strict identity + // against one is bit identity too. + let either_class_value = + is_class_value_expr(ctx, left) || is_class_value_expr(ctx, right); + if (either_proven_symbol || either_class_value) + && matches!(op, CompareOp::Eq | CompareOp::Ne) + { let blk = ctx.block(); let l_bits = blk.bitcast_double_to_i64(&l); let r_bits = blk.bitcast_double_to_i64(&r); diff --git a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs index 7b822031c2..6bee5f3c97 100644 --- a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs +++ b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs @@ -888,7 +888,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { if let Some(&cid) = ctx.class_ids.get(name).filter(|_| { !ctx.imported_vars.contains(name) && !ctx.namespace_imports.contains(name) }) { - return Ok(super::emit_class_value(ctx.block(), cid)); + return Ok(super::emit_class_value_cached(ctx, cid)); } // Issue #841: named imports from Node submodules Perry recognizes // as runtime-backed values must win over the generic native-module diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index ba3dd3e590..05aa2e8f67 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -24,6 +24,40 @@ pub(crate) fn emit_class_value(blk: &mut LlBlock, class_id: u32) -> String { &[(I32, &(class_id as i32).to_string())], ) } + +/// [`emit_class_value`] behind a per-site cache: a zero-initialised global +/// (thread-local when the program starts workers, so each agent caches its +/// own class object) holds the NaN-boxed value after the first use. The object +/// is pinned for the agent's life, so the cached bits never go stale and the +/// slot needs no root. +pub(crate) fn emit_class_value_cached(ctx: &mut FnCtx<'_>, class_id: u32) -> String { + let site = ctx.ic_site_counter; + ctx.ic_site_counter += 1; + let slot = format!("@{}_classval", inline_cache_global_name(ctx, site)); + let tls = if crate::codegen::program_has_worker() { + "thread_local " + } else { + "" + }; + ctx.typed_parse_rodata + .push(format!("{slot} = private {tls}global double 0.0, align 8")); + let cached = ctx.block().load(DOUBLE, &slot); + let bits = ctx.block().bitcast_double_to_i64(&cached); + let empty = ctx.block().icmp_eq(I64, &bits, "0"); + let from_l = ctx.block_label(ctx.current_block); + let miss_idx = ctx.new_block("classval.miss"); + let join_idx = ctx.new_block("classval.join"); + let miss_l = ctx.block_label(miss_idx); + let join_l = ctx.block_label(join_idx); + ctx.block().cond_br(&empty, &miss_l, &join_l); + ctx.current_block = miss_idx; + let fresh = emit_class_value(ctx.block(), class_id); + ctx.block().store(DOUBLE, &fresh, &slot); + ctx.block().br(&join_l); + ctx.current_block = join_idx; + ctx.block() + .phi(DOUBLE, &[(&cached, &from_l), (&fresh, &miss_l)]) +} use crate::codegen::AppMetadata; use crate::collectors::NativeRegionFactGraph; use crate::function::LlFunction; diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index fae5f74968..85b78c997d 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -1140,7 +1140,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { .get(&crate::namespace_member_class_key(name, property)) .copied(); if let Some(cid) = class_cid { - return Ok(super::emit_class_value(ctx.block(), cid)); + return Ok(super::emit_class_value_cached(ctx, cid)); } // Issue #680: prefer the per-namespace map so // `random.make` and `tracer.make` resolve to their diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index adbb9936f9..d13eb48cc5 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -700,7 +700,7 @@ fn lower_new_impl_inner<'a>( // rewrites — so it goes in a temp root, not a bare register. let saved_new_target = if ctor_chain_uses_new_target(ctx, class) { ctx.class_ids.get(class_name).copied().map(|cid| { - let class_ref = crate::expr::emit_class_value(ctx.block(), cid); + let class_ref = crate::expr::emit_class_value_cached(ctx, cid); crate::rooting::new_target_save(ctx, &class_ref) }) } else { @@ -1004,7 +1004,7 @@ fn lower_new_impl_inner<'a>( // `new.target === C`, `new.target.name`, and `new.target.prototype` all // work. Falls back to `undefined` if the class id is somehow unresolved. let new_target_value = match ctx.class_ids.get(class_name).copied() { - Some(cid) => crate::expr::emit_class_value(ctx.block(), cid), + Some(cid) => crate::expr::emit_class_value_cached(ctx, cid), None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), }; let new_target_slot = ctx.func.alloca_entry(DOUBLE); @@ -1714,7 +1714,7 @@ fn lower_new_impl_inner<'a>( // was mis-categorized and the login redirect fell back to // `?error=Configuration`. let nt_ref = match ctx.class_ids.get(class_name).copied() { - Some(cid) => crate::expr::emit_class_value(ctx.block(), cid), + Some(cid) => crate::expr::emit_class_value_cached(ctx, cid), None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), }; let nt_save = crate::rooting::new_target_save(ctx, &nt_ref); @@ -1752,7 +1752,7 @@ fn lower_new_impl_inner<'a>( // class ref around the imported ctor call (see the ANCESTOR arm // above for why). This is the direct `new ImportedClass()` case. let nt_ref = match ctx.class_ids.get(class_name).copied() { - Some(cid) => crate::expr::emit_class_value(ctx.block(), cid), + Some(cid) => crate::expr::emit_class_value_cached(ctx, cid), None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), }; let nt_save = crate::rooting::new_target_save(ctx, &nt_ref); diff --git a/crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs b/crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs index 39f63c5d4b..0adb171ff5 100644 --- a/crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs +++ b/crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs @@ -135,9 +135,7 @@ pub(crate) fn try_lower_static_dispatch( _ => { // Synthesize a ClassRef NaN-box from the resolved class. match ctx.class_ids.get(&cls_name).copied() { - Some(cid) if cid != 0 => { - crate::expr::emit_class_value(ctx.block(), cid) - } + Some(cid) if cid != 0 => crate::expr::emit_class_value_cached(ctx, cid), _ => crate::nanbox::double_literal(f64::from_bits( crate::nanbox::TAG_UNDEFINED, )), diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs index d981b3fd89..de00b7bf45 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs @@ -398,6 +398,7 @@ pub(crate) fn declare_core(module: &mut LlModule) { // armed by dynamic static dispatch / call/apply, else returns the // lexical class-ref argument. module.declare_function("js_static_this_resolve", DOUBLE, &[DOUBLE]); + module.declare_function("js_static_this_resolve_class", DOUBLE, &[I32]); module.declare_function("js_static_this_arm_classref", VOID, &[I32]); module.declare_function("js_static_this_arm_value", VOID, &[DOUBLE]); module.declare_function("js_ctor_return_override", DOUBLE, &[DOUBLE, DOUBLE, I32]); diff --git a/crates/perry-codegen/src/stmt/let_scalar_new.rs b/crates/perry-codegen/src/stmt/let_scalar_new.rs index bc0536bde3..19bb935e8d 100644 --- a/crates/perry-codegen/src/stmt/let_scalar_new.rs +++ b/crates/perry-codegen/src/stmt/let_scalar_new.rs @@ -172,7 +172,7 @@ pub(super) fn try_lower_scalar_replaced_new( // the ctor (notably `const t = new.target`) fell through to the // runtime cell, which this path never sets, yielding undefined. let new_target_value = match ctx.class_ids.get(class_name).copied() { - Some(cid) => crate::expr::emit_class_value(ctx.block(), cid), + Some(cid) => crate::expr::emit_class_value_cached(ctx, cid), None => { crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) } diff --git a/crates/perry-runtime/src/builtins/formatting.rs b/crates/perry-runtime/src/builtins/formatting.rs index 627f971682..0e2a9db0b3 100644 --- a/crates/perry-runtime/src/builtins/formatting.rs +++ b/crates/perry-runtime/src/builtins/formatting.rs @@ -265,7 +265,7 @@ fn format_function_for_console(closure_ptr: *const crate::closure::ClosureHeader if closure_ptr.is_null() { return "[Function (anonymous)]".to_string(); } - if let Some(class_id) = crate::object::class_closure_id(closure_ptr as usize) { + if let Some(class_id) = crate::object::class_value::class_closure_id(closure_ptr as usize) { return format_class_for_console(class_id, closure_ptr); } diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 757fa71150..2ee829e442 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -1208,6 +1208,8 @@ pub fn gc_init() { // capture heap words, so copied-minor must rewrite them after moving // captured young values or future cache hits miss on stale addresses. reg_scanner!(crate::closure::scan_singleton_closure_roots_mut); + // The per-agent class function objects (`object::class_value`). + reg_scanner!(crate::object::class_value::scan_class_value_roots_mut); reg_scanner!(crate::closure::scan_closure_dynamic_props_roots_mut); // #8393: built-in prototype methods carry per-closure identity metadata // keyed by their raw heap address. Copying minor GC moves those closures; diff --git a/crates/perry-runtime/src/node_vm.rs b/crates/perry-runtime/src/node_vm.rs index 5890405711..af246c600b 100644 --- a/crates/perry-runtime/src/node_vm.rs +++ b/crates/perry-runtime/src/node_vm.rs @@ -230,7 +230,7 @@ pub(crate) fn compiled_function_source_for_closure(closure: usize) -> Option String { // A class function object renders its class's retained source. - if let Some(class_id) = crate::object::class_closure_id(closure) { + if let Some(class_id) = crate::object::class_value::class_closure_id(closure) { return crate::object::class_ref_to_string(class_id).into_owned(); } compiled_function_source_for_closure(closure).unwrap_or_else(|| { diff --git a/crates/perry-runtime/src/object/class_registry/construct.rs b/crates/perry-runtime/src/object/class_registry/construct.rs index 75df56e279..383672d44c 100644 --- a/crates/perry-runtime/src/object/class_registry/construct.rs +++ b/crates/perry-runtime/src/object/class_registry/construct.rs @@ -275,6 +275,13 @@ pub unsafe extern "C-unwind" fn js_new_function_construct( args_ptr: *const f64, args_len: usize, ) -> f64 { + // A class value (its function object, or the legacy immediate) constructs + // its class: decided first, one closure probe, before the exotic arms. + if let Some(class_cid) = constructor_class_ref_id(func_value) { + return construct_registered_class_ref( + class_cid, class_cid, func_value, args_ptr, args_len, + ); + } // `new ()` is a TypeError — a primitive is never a constructor // (`new undefined()`, `new 5n()`, `new "s"()`, `new true()`). Checked via // the unambiguous NaN-box tags only (NOT `is_number`, whose f64 range @@ -1250,7 +1257,7 @@ pub unsafe extern "C" fn js_new_function_construct_apply(func_value: f64, args_a } fn constructor_class_ref_id(value: f64) -> Option { - super::super::class_value_id(value) + super::super::class_value::class_value_id(value) } /// Spec `IsConstructor(value)` — used by `NewPromiseCapability` (the Promise diff --git a/crates/perry-runtime/src/object/class_value.rs b/crates/perry-runtime/src/object/class_value.rs index 8eff078bec..10528374bd 100644 --- a/crates/perry-runtime/src/object/class_value.rs +++ b/crates/perry-runtime/src/object/class_value.rs @@ -175,9 +175,7 @@ fn class_value_mint(class_id: u32) -> *mut ClosureHeader { // Born old AND pinned: the address is the class's identity for the // agent's life (compiled code keeps it in registers and allocas, the // metadata and weak tables compare it), so no collector may move it. - crate::gc::pin_object_non_young( - (ptr as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader - ); + crate::gc::pin_user_ptr_non_young(ptr as *mut u8); } let page = (class_id >> CLASS_VALUE_PAGE_SHIFT) as usize; let index = class_id as usize & (CLASS_VALUE_PAGE_LEN - 1); @@ -218,8 +216,8 @@ pub extern "C" fn js_class_value(class_id: i32) -> f64 { class_value(class_id as u32) } -/// GC root scan for [`CLASS_VALUES`]; registered from -/// `object::scan_object_cache_roots_mut`. +/// GC root scan for [`CLASS_VALUES`]; registered in `gc::mod`'s runtime +/// scanner list. pub(crate) fn scan_class_value_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { CLASS_VALUES.with(|t| { let mut t = t.borrow_mut(); @@ -232,3 +230,85 @@ pub(crate) fn scan_class_value_roots_mut(visitor: &mut crate::gc::RuntimeRootVis } }); } + +#[cfg(test)] +mod tests { + use super::*; + + fn register(cid: u32) { + let mut guard = crate::object::REGISTERED_CLASS_IDS.write().unwrap(); + guard + .get_or_insert_with(crate::fast_hash::new_ptr_hash_set) + .insert(cid); + } + + /// #11414: a class value is ONE function object per class — never an + /// INT32 word a number can equal — pinned and old, so its address is its + /// identity across collections. + #[test] + fn class_value_is_one_pinned_function_object_per_class() { + let cid = 0x6A01; + register(cid); + let a = class_value(cid); + let b = class_value(cid); + assert_eq!(a.to_bits(), b.to_bits(), "one function object per class"); + let v = crate::value::JSValue::from_bits(a.to_bits()); + assert!( + !v.is_int32() && !v.is_number(), + "a class value is not a number" + ); + assert!(v.is_pointer()); + let ptr = (a.to_bits() & crate::value::POINTER_MASK) as usize; + assert!( + crate::closure::is_closure_ptr(ptr), + "a GC_TYPE_CLOSURE cell" + ); + assert_eq!(class_value_id(a), Some(cid)); + assert_eq!(class_closure_id(ptr), Some(cid)); + // The number equal to the class id is not the class. + assert_ne!( + f64::from_bits(crate::value::INT32_TAG | cid as u64).to_bits(), + a.to_bits() + ); + let header = unsafe { crate::value::addr_class::try_read_gc_header(ptr) }.expect("header"); + assert_ne!(header.gc_flags & crate::gc::GC_FLAG_PINNED, 0, "pinned"); + assert_ne!(header.gc_flags & crate::gc::GC_FLAG_TENURED, 0, "born old"); + crate::gc::js_gc_collect(); + assert_eq!(class_value(cid).to_bits(), a.to_bits(), "never moves"); + assert_eq!(class_value_id(a), Some(cid), "survives a full collection"); + let other = class_value(0x6A02); + assert_ne!(other.to_bits(), a.to_bits()); + assert_eq!(class_value_id(other), Some(0x6A02)); + } + + /// The table is a root: the scan visits every minted class value. + #[test] + fn class_value_table_is_scanned() { + let cid = 0x6B01; + register(cid); + let ptr = class_value_ptr(cid) as usize; + let mut seen = false; + scan_class_value_roots_mut(&mut crate::gc::RuntimeRootVisitor::for_copy( + &mut |v: f64| { + let bits = v.to_bits(); + if bits as usize == ptr || (bits & crate::value::POINTER_MASK) as usize == ptr { + seen = true; + } + }, + )); + assert!(seen, "the class-value table must be a GC root"); + } + + /// Only the function object's own code pointer names a class. + #[test] + fn ordinary_closures_and_numbers_are_not_class_values() { + extern "C" fn body() {} + let c = crate::closure::js_closure_alloc(body as *const u8, 0); + assert_eq!(class_closure_id(c as usize), None); + assert_eq!(class_value_id(42.0), None); + assert_eq!( + class_value_id(f64::from_bits(crate::value::TAG_UNDEFINED)), + None + ); + } +} diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index cdcfd1e1f9..3b0e851fcd 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -95,8 +95,6 @@ pub(crate) use class_registry::{construct_rooted_arguments, scan_current_new_tar pub(crate) mod accessor_pair; #[cfg(feature = "attr-census")] pub(crate) mod attr_census; -pub(crate) use class_value::class_value_id; -pub use class_value::{class_closure_id, js_class_constructor_called, js_class_value}; pub(crate) mod canonical_keys; mod census; pub(crate) mod key_attrs; @@ -375,7 +373,7 @@ pub(crate) use this_binding::{ pub use this_binding::{ js_implicit_this_get, js_implicit_this_get_sloppy, js_implicit_this_set, js_new_target_get, js_new_target_set, js_static_this_arm_classref, js_static_this_arm_value, - js_static_this_resolve, ImplicitThisScope, + js_static_this_resolve, js_static_this_resolve_class, ImplicitThisScope, }; pub use to_string_tag::js_object_to_string; pub(crate) use to_string_tag::typed_array_to_string_tag_name; @@ -1557,7 +1555,6 @@ pub fn scan_object_cache_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<' // holding it is a real GC root that a moving collection must rewrite. null_stub::scan_null_stub_roots_mut(visitor); crate::closure::shape::scan_function_prototype_roots_mut(visitor); - class_value::scan_class_value_roots_mut(visitor); #[cfg(feature = "regex-engine")] regex_proto_thunks::scan_canonical_test_site_roots_mut(visitor); } diff --git a/crates/perry-runtime/src/object/this_binding.rs b/crates/perry-runtime/src/object/this_binding.rs index f996dd19f4..b43dfff694 100644 --- a/crates/perry-runtime/src/object/this_binding.rs +++ b/crates/perry-runtime/src/object/this_binding.rs @@ -172,6 +172,29 @@ pub extern "C" fn js_static_this_resolve(default_this: f64) -> f64 { }) } +/// [`js_static_this_resolve`] for a static method of class `class_id`: the +/// armed override if any, else the class's function object — without the +/// caller materializing the default on every call. +// #1561-style force-keep: only generated IR calls this. +#[cfg(feature = "keepalive-anchors")] +#[used(compiler)] +static KEEP_JS_STATIC_THIS_RESOLVE_CLASS: extern "C" fn(i32) -> f64 = js_static_this_resolve_class; + +#[no_mangle] +pub extern "C" fn js_static_this_resolve_class(class_id: i32) -> f64 { + let armed = STATIC_THIS_OVERRIDE.with(|c| { + let (armed, bits) = c.get(); + if armed { + c.set((false, crate::value::TAG_UNDEFINED)); + } + armed.then_some(bits) + }); + match armed { + Some(bits) => f64::from_bits(bits), + None => super::class_value::class_value(class_id as u32), + } +} + /// Read the current implicit `this` (issue #519). #[no_mangle] pub extern "C" fn js_implicit_this_get() -> f64 { diff --git a/crates/perry-runtime/src/proxy/apply_construct.rs b/crates/perry-runtime/src/proxy/apply_construct.rs index e4e90e2fec..8eea0862a2 100644 --- a/crates/perry-runtime/src/proxy/apply_construct.rs +++ b/crates/perry-runtime/src/proxy/apply_construct.rs @@ -19,7 +19,7 @@ use crate::closure::js_closure_call3; pub(crate) fn is_callable_function(value: f64) -> bool { let bits = value.to_bits(); // Class-ref constructors (INT32-tagged, top16 == 0x7FFE) are callable. - if (bits >> 48) == 0x7FFE || crate::object::class_value_id(value).is_some() { + if (bits >> 48) == 0x7FFE || crate::object::class_value::class_value_id(value).is_some() { return crate::object::class_ref_id(value).is_some(); } // A proxy whose target is callable is itself callable. diff --git a/crates/perry-runtime/src/proxy/metadata.rs b/crates/perry-runtime/src/proxy/metadata.rs index daca302dab..9dcfd43f29 100644 --- a/crates/perry-runtime/src/proxy/metadata.rs +++ b/crates/perry-runtime/src/proxy/metadata.rs @@ -124,7 +124,7 @@ fn normalize_target_bits(target: f64) -> u64 { } // A class constructor (either form) -> its stable key: the function object // is a heap cell whose address is not a durable key. - if let Some(cid) = crate::object::class_value_id(target) { + if let Some(cid) = crate::object::class_value::class_value_id(target) { return crate::object::class_constructor_key_bits(cid); } // Live decl-prototype heap object → fold onto the class constructor key. @@ -308,13 +308,14 @@ mod tests { fn synthetic_prototype_ref_folds_onto_constructor_key() { let cid = 0x4242; register_test_class(cid); - let ctor_key = crate::object::class_constructor_ref_value(cid).to_bits(); + let ctor_key = crate::object::class_constructor_key_bits(cid); let proto_ref = crate::object::class_prototype_ref_value(cid); assert_eq!(normalize_target_bits(proto_ref), ctor_key); - // The constructor ref already IS the key — must pass through unchanged. + // The constructor VALUE (its function object) folds onto the same key. let ctor_ref = crate::object::class_constructor_ref_value(cid); + assert_ne!(ctor_ref.to_bits(), ctor_key, "the value is not the key"); assert_eq!(normalize_target_bits(ctor_ref), ctor_key); } @@ -333,7 +334,7 @@ mod tests { let target = f64::from_bits(POINTER_TAG | (fake_proto_ptr as u64 & POINTER_MASK)); assert_eq!( normalize_target_bits(target), - crate::object::class_constructor_ref_value(cid).to_bits() + crate::object::class_constructor_key_bits(cid) ); } From db5918c3c21b3a48a924d413c681fbf2f8573121 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 26 Sep 2026 13:53:36 +0000 Subject: [PATCH 04/29] perf(runtime): reject an ordinary function before proving a class function object class_closure_id runs on hot generic paths (bind targets, bound-method receivers, method values) for every pointer. It proved ownership first (is_closure_ptr: heap-generation classification and a tracked header read), which cost Zod +4.2% instructions (is_closure_ptr +2.3%). An exotic-band ShapeId word followed by a code pointer other than js_class_constructor_called now rejects before the proof; only a class function object is proven. --- .../perry-runtime/src/object/class_value.rs | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/object/class_value.rs b/crates/perry-runtime/src/object/class_value.rs index 10528374bd..59735ba76a 100644 --- a/crates/perry-runtime/src/object/class_value.rs +++ b/crates/perry-runtime/src/object/class_value.rs @@ -42,9 +42,31 @@ pub(crate) fn class_value_id_bits(bits: u64) -> Option { /// The class id of a class function object at raw address `ptr`, or `None` /// for any other word. Ownership is proven (`is_closure_ptr`) before a header /// byte is trusted, so arbitrary addresses are fine. +/// +/// Callers include hot generic paths (bind, method values), so an ordinary +/// function is rejected before the ownership proof: once the address is a +/// plausible, aligned heap address whose ShapeId word is in the exotic band +/// (the same pre-checks `is_closure_ptr` makes before its first load), the +/// code-pointer word at +8 — inside every exotic cell's header — must be this +/// module's thunk. Only then is the cell proven. #[inline] pub fn class_closure_id(ptr: usize) -> Option { - if !crate::closure::is_closure_ptr(ptr) { + if !crate::value::addr_class::is_plausible_heap_addr(ptr) + || !ptr.is_multiple_of(std::mem::align_of::()) + { + return None; + } + // SAFETY: a plausible, aligned heap address (the contract of the + // `is_closure_ptr` pre-checks this mirrors). + let shape = + unsafe { *((ptr as *const u8).add(crate::closure::CLOSURE_SHAPE_OFFSET) as *const u32) }; + if !crate::object::shapes::is_exotic_shape_id(shape) { + return None; + } + // SAFETY: an exotic-band ShapeId word means a closure-or-exotic header, + // at least 16 bytes; +8 is the code pointer of a closure. + let code = unsafe { *((ptr as *const u8).add(8) as *const *const u8) }; + if code != js_class_constructor_called as *const u8 || !crate::closure::is_closure_ptr(ptr) { return None; } // SAFETY: `is_closure_ptr` proved a live, non-forwarded closure cell. From 2e6b14528b1760195b9db5a97e48bfa0b299b2eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 26 Sep 2026 13:56:35 +0000 Subject: [PATCH 05/29] refactor(runtime): class statics are the class function object's own properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 3a of class constructors as function objects. A class's static data properties (declared static fields and runtime `C.x = v`) were a cid-keyed side table (CLASS_DYNAMIC_PROPS + CLASS_DYNAMIC_PROP_ORDER) that only the class branches consulted. They are now slots of the class function object's own-property bag (closure::props, D1) — traced as the object's child edge, barriered, in creation order — and runtime-internal static keys (private statics, computed-key records, class captures) live in the object's internal state record, never as properties. The storage primitives (class_dynamic_prop_root_store, class_own_static_field_value, class_own_dynamic_prop_names, class_has_own_dynamic_prop, class_delete_own_dynamic_prop) keep their signatures over object::class_value::class_static_{get,set,remove,entries}; the seven direct readers use them; both tables, their root scans and the incremental root-slot variant are deleted. GC tests that seeded a static as a root slot now seed a class-table root that still is one; the copying test keeps checking a young static is moved and rewritten (now through the bag). Because the generic closure paths read the bag, `{...C}`, Object.assign, Object.entries and for-in see a class's statics (they saw nothing). A `static { }` block is no longer registered as a static method, so `__perry_static_init_N` stops leaking into Reflect.ownKeys / getOwnPropertyNames. --- .../perry-codegen/src/codegen/string_pool.rs | 6 + crates/perry-runtime/src/closure/props.rs | 43 +++++++ .../perry-runtime/src/gc/tests/cycle_state.rs | 2 +- .../tests/runtime_roots/callback_scanners.rs | 5 - .../runtime_roots/side_table_scanners.rs | 10 +- .../src/object/class_registry/gc_roots.rs | 48 +------- .../object/class_registry/parent_static.rs | 3 +- .../src/object/class_registry/state.rs | 108 +++--------------- .../perry-runtime/src/object/class_value.rs | 63 ++++++++++ .../object/field_get_set/get_field_by_name.rs | 13 +-- .../src/object/field_set_by_name.rs | 6 +- crates/perry-runtime/src/object/mod.rs | 13 --- .../native_call_method/common_methods.rs | 14 +-- .../src/object/object_ops/has_own.rs | 7 +- .../perry-runtime/src/object/property_key.rs | 4 +- .../test_gap_class_value_statics_own.ts | 11 ++ 16 files changed, 161 insertions(+), 195 deletions(-) create mode 100644 test-files/test_gap_class_value_statics_own.ts diff --git a/crates/perry-codegen/src/codegen/string_pool.rs b/crates/perry-codegen/src/codegen/string_pool.rs index 079c4ce0ec..f43d3e5b3a 100644 --- a/crates/perry-codegen/src/codegen/string_pool.rs +++ b/crates/perry-codegen/src/codegen/string_pool.rs @@ -969,6 +969,12 @@ pub(super) fn emit_string_pool( // #1788: static methods are emitted as `perry_static_*` (no `this` // param). Collect them for the runtime CLASS_STATIC_METHODS table. for sm in &class.static_methods { + // A `static { }` block is lowered to a synthetic static method the + // class's initializer calls directly. It is not a member: never + // registered, so no reflection (`Reflect.ownKeys(C)`) can see it. + if sm.name.starts_with("__perry_static_init_") { + continue; + } let llvm_name = scoped_static_method_name(module_prefix, cid, class_name, &sm.name); let has_rest = sm.params.last().map(|p| p.is_rest).unwrap_or(false); // Spec `.length`: leading formal params before the first default/rest diff --git a/crates/perry-runtime/src/closure/props.rs b/crates/perry-runtime/src/closure/props.rs index 8d2d7e1478..88eaacdcac 100644 --- a/crates/perry-runtime/src/closure/props.rs +++ b/crates/perry-runtime/src/closure/props.rs @@ -24,6 +24,7 @@ use crate::value::JSValue; const STATE_PROTO: &str = "p"; const DELETED_PREFIX: &str = "d:"; +const INTERNAL_PREFIX: &str = "i:"; /// The bag of the closure at `ptr` (null when it never had an own property). /// @@ -272,6 +273,48 @@ pub(crate) unsafe fn state_set_prototype(ptr: usize, proto_bits: u64) { object_own_set(state, STATE_PROTO, f64::from_bits(proto_bits)); } +/// A runtime-internal own slot of the function object — never a JS property +/// (class private statics, computed-key records, class captures): kept in the +/// state record under `"i:" + key`, so no reflection or enumeration of the +/// function can reach it. +/// +/// # Safety +/// `ptr` is a proven, live closure cell. +pub(crate) unsafe fn state_internal_get(ptr: usize, key: &str) -> Option { + let state = state_of(ptr); + if state.is_null() { + return None; + } + let mut marker = String::with_capacity(INTERNAL_PREFIX.len() + key.len()); + marker.push_str(INTERNAL_PREFIX); + marker.push_str(key); + object_own_get(state, marker.as_bytes()) +} + +/// # Safety +/// `ptr` is a proven, live closure cell. +pub(crate) unsafe fn state_internal_set(ptr: usize, key: &str, value: f64) { + let _no_move = crate::gc::GcSuppressScope::new(); + let Some(state) = state_ensure(ptr) else { + return; + }; + object_own_set(state, &format!("{INTERNAL_PREFIX}{key}"), value); +} + +/// # Safety +/// `ptr` is a proven, live closure cell. +pub(crate) unsafe fn state_internal_remove(ptr: usize, key: &str) -> bool { + if state_internal_get(ptr, key).is_none() { + return false; + } + let _no_move = crate::gc::GcSuppressScope::new(); + let state = state_of(ptr); + let marker = format!("{INTERNAL_PREFIX}{key}"); + let key_hdr = crate::string::js_string_from_bytes(marker.as_ptr(), marker.len() as u32); + crate::object::js_object_delete_field(state, key_hdr); + true +} + /// True when the closure carries internal state a base/keyed Function shape /// cannot describe (a deleted marker or a recorded prototype). /// diff --git a/crates/perry-runtime/src/gc/tests/cycle_state.rs b/crates/perry-runtime/src/gc/tests/cycle_state.rs index 5a292815b5..3251a3fdeb 100644 --- a/crates/perry-runtime/src/gc/tests/cycle_state.rs +++ b/crates/perry-runtime/src/gc/tests/cycle_state.rs @@ -564,7 +564,7 @@ fn root_scan_slices_many_registered_class_side_table_roots_with_tiny_budget() { const ROOTS: usize = 32; let children = (0..ROOTS).map(|_| young_leaf()).collect::>(); for (idx, &child) in children.iter().enumerate() { - crate::object::test_seed_class_dynamic_prop_root( + crate::object::test_seed_class_prototype_method_root( 0x5300 + idx as u32, "root", string_bits(child), diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/callback_scanners.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/callback_scanners.rs index 28c71731df..03929ff367 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/callback_scanners.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/callback_scanners.rs @@ -1325,7 +1325,6 @@ fn test_gc_init_mutable_scanner_families_rewrite_runtime_slots() { ); crate::object::test_seed_transition_cache_root(fixture.nursery_addr()); crate::object::test_seed_object_cache_roots([fixture.nursery_bits; 7], fixture.nursery_i64()); - crate::object::test_seed_class_dynamic_prop_root(0x5501, "dyn", fixture.nursery_bits); crate::object::test_seed_class_prototype_method_root(0x5501, "proto", fixture.nursery_bits); crate::object::test_seed_class_prototype_method_value_root( 0x5501, @@ -1477,10 +1476,6 @@ fn test_gc_init_mutable_scanner_families_rewrite_runtime_slots() { crate::object::test_object_cache_roots(), ([fixture.old_bits; 7], fixture.old_addr() as i64) ); - assert_eq!( - crate::object::test_class_dynamic_prop_root_bits(0x5501, "dyn"), - fixture.old_bits - ); assert_eq!( crate::object::test_class_prototype_method_root_bits(0x5501, "proto"), fixture.old_bits diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/side_table_scanners.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/side_table_scanners.rs index 4e14a61002..1a1943d11f 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/side_table_scanners.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/side_table_scanners.rs @@ -64,7 +64,7 @@ fn test_implicit_this_root_scanner_marks_and_rewrites() { #[test] fn test_class_side_table_scanner_marks_values_but_not_function_keys() { let _guard = GcTestIsolationGuard::new(); - // `dynamic_value`/`prototype_value`/`cached_value`/`prototype_object` are + // `prototype_value`/`cached_value`/`prototype_object` are // all live across the two `arena_alloc_gc` calls below (`parent_closure`, // `function_key`), and `parent_closure` is live across `function_key`'s — // any of those allocations can reach the block-full slow path's @@ -74,7 +74,6 @@ fn test_class_side_table_scanner_marks_values_but_not_function_keys() { clear_mark_seeds(); crate::object::test_clear_class_side_table_roots(); - let dynamic_value = young_leaf(); let prototype_value = young_leaf(); let cached_value = young_leaf(); let prototype_object = crate::object::js_object_alloc(0, 0) as usize; @@ -93,7 +92,6 @@ fn test_class_side_table_scanner_marks_values_but_not_function_keys() { init_test_closure(function_key as *mut u8); } - crate::object::test_seed_class_dynamic_prop_root(0x5201, "dyn", string_bits(dynamic_value)); crate::object::test_seed_class_prototype_method_root( 0x5201, "proto", @@ -111,7 +109,6 @@ fn test_class_side_table_scanner_marks_values_but_not_function_keys() { let valid_ptrs = build_valid_pointer_set(); crate::object::scan_class_side_table_roots_mut(&mut RuntimeRootVisitor::for_mark(&valid_ptrs)); - assert_marked_user_ptr(dynamic_value, "dynamic class property value"); assert_marked_user_ptr(prototype_value, "prototype method value"); assert_marked_user_ptr(cached_value, "cached bound prototype method value"); assert_marked_user_ptr(prototype_object, "prototype-object side-table value"); @@ -159,7 +156,6 @@ fn test_registered_class_side_table_scanner_rewrites_values_and_function_keys() let value_old_bits = ptr_bits(value_old as usize); let key_bits = ptr_bits(key_user as usize); let key_old_bits = ptr_bits(key_old as usize); - crate::object::test_seed_class_dynamic_prop_root(0x5202, "dyn", value_bits); crate::object::test_seed_class_prototype_method_root(0x5202, "proto", value_bits); crate::object::test_seed_class_prototype_method_value_root(0x5202, "bound", value_bits); crate::object::test_seed_class_prototype_object_root(0x5202, value_user as usize); @@ -168,10 +164,6 @@ fn test_registered_class_side_table_scanner_rewrites_values_and_function_keys() rewrite_mutable_registered_roots(&valid_ptrs); - assert_eq!( - crate::object::test_class_dynamic_prop_root_bits(0x5202, "dyn"), - value_old_bits - ); assert_eq!( crate::object::test_class_prototype_method_root_bits(0x5202, "proto"), value_old_bits diff --git a/crates/perry-runtime/src/object/class_registry/gc_roots.rs b/crates/perry-runtime/src/object/class_registry/gc_roots.rs index 2d5178493a..0a5491fc71 100644 --- a/crates/perry-runtime/src/object/class_registry/gc_roots.rs +++ b/crates/perry-runtime/src/object/class_registry/gc_roots.rs @@ -2,10 +2,6 @@ use super::*; #[derive(Clone)] enum ClassSideTableRootSlot { - DynamicProp { - class_id: u32, - name: String, - }, PrototypeMethod { class_id: u32, name: String, @@ -81,15 +77,6 @@ pub fn scan_class_side_table_roots(mark: &mut dyn FnMut(f64)) { } pub fn scan_class_side_table_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { - CLASS_DYNAMIC_PROPS.with(|m| { - let mut m = m.borrow_mut(); - for props in m.values_mut() { - for value in props.values_mut() { - visitor.visit_nanbox_f64_slot(value); - } - } - }); - CLASS_PROTOTYPE_METHODS.with(|table| { if let Ok(mut guard) = table.write() { if let Some(map) = guard.as_mut() { @@ -235,18 +222,6 @@ fn scan_class_symbol_member_keys_mut(visitor: &mut crate::gc::RuntimeRootVisitor fn class_side_table_root_snapshot() -> Vec { let mut slots = Vec::new(); - CLASS_DYNAMIC_PROPS.with(|m| { - let m = m.borrow(); - for (&class_id, props) in m.iter() { - for name in props.keys() { - slots.push(ClassSideTableRootSlot::DynamicProp { - class_id, - name: name.clone(), - }); - } - } - }); - CLASS_PROTOTYPE_METHODS.with(|table| { if let Ok(guard) = table.read() { if let Some(map) = guard.as_ref() { @@ -385,17 +360,6 @@ fn scan_class_side_table_root_slot( slot: &ClassSideTableRootSlot, ) { match slot { - ClassSideTableRootSlot::DynamicProp { class_id, name } => { - CLASS_DYNAMIC_PROPS.with(|m| { - if let Some(value) = m - .borrow_mut() - .get_mut(class_id) - .and_then(|props| props.get_mut(name)) - { - visitor.visit_nanbox_f64_slot(value); - } - }); - } ClassSideTableRootSlot::PrototypeMethod { class_id, name } => { CLASS_PROTOTYPE_METHODS.with(|table| { if let Ok(mut guard) = table.write() { @@ -657,9 +621,7 @@ pub(crate) fn test_clear_class_side_table_roots() { // Disambiguate: CLASS_DELETED_KEYS is reachable via both `use super::*` // and `use crate::object::*`; name the canonical definition explicitly. use super::state::CLASS_DELETED_KEYS; - CLASS_DYNAMIC_PROPS.with(|m| m.borrow_mut().clear()); super::state::CLASS_DECLARED_STATIC_GLOBAL_SLOTS.with(|m| m.borrow_mut().clear()); - crate::object::CLASS_DYNAMIC_PROP_ORDER.with(|order| order.borrow_mut().clear()); CLASS_DELETED_KEYS.with(|m| m.borrow_mut().clear()); CLASS_PROTOTYPE_METHOD_VALUES.with(|cache| cache.borrow_mut().clear()); CLASS_PROTOTYPE_METHODS.with(|table| { @@ -731,13 +693,9 @@ pub(crate) fn test_seed_class_dynamic_prop_root(class_id: u32, name: &str, value #[cfg(test)] pub(crate) fn test_class_dynamic_prop_root_bits(class_id: u32, name: &str) -> u64 { - CLASS_DYNAMIC_PROPS.with(|m| { - m.borrow() - .get(&class_id) - .and_then(|props| props.get(name)) - .map(|value| value.to_bits()) - .unwrap_or(0) - }) + crate::object::class_value::class_static_get(class_id, name) + .map(f64::to_bits) + .unwrap_or(0) } #[cfg(test)] 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 b6fcc3ddd5..4b50d77765 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -1689,8 +1689,7 @@ pub unsafe extern "C" fn js_class_static_method_call( let mut cid = class_id; let mut depth = 0u32; while cid != 0 && depth < 64 { - let field_val = CLASS_DYNAMIC_PROPS - .with(|m| m.borrow().get(&cid).and_then(|f| f.get(name).copied())); + let field_val = crate::object::class_value::class_static_get(cid, name); if let Some(v) = field_val { let fv = crate::value::JSValue::from_bits(v.to_bits()); if !fv.is_undefined() && !fv.is_null() { diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index c9a7a07b54..3afeb4a2ef 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -91,55 +91,20 @@ pub(crate) fn class_unmark_key_deleted(class_id: u32, key: &str) { /// (`class C { m() {} static m = 1 }` — both land under one class_id) keeps /// whatever behaviour it had. pub(crate) fn class_dynamic_prop_root_store(class_id: u32, name: &str, value: f64) { - let nothing_deleted = CLASS_DELETED_KEYS.with(|m| m.borrow().is_empty()); - if nothing_deleted { - let updated = CLASS_DYNAMIC_PROPS.with(|m| { - match m - .borrow_mut() - .get_mut(&class_id) - .and_then(|props| props.get_mut(name)) - { - Some(slot) => { - *slot = value; - true - } - None => false, - } - }); - if updated { - crate::gc::runtime_write_barrier_root_nanbox(value.to_bits()); - return; - } - } else { - // Un-marking re-exposes a previously `delete`d prototype key to - // `class_instance_has_member` / `lookup_prototype_method` — the one - // direction a cached "this chain resolves nothing" verdict must not - // survive (#10696). - CLASS_DELETED_KEYS.with(|m| { - if let Some(keys) = m.borrow_mut().get_mut(&class_id) { - keys.remove(name); - } - }); + // Un-marking re-exposes a previously `delete`d prototype key to + // `class_instance_has_member` / `lookup_prototype_method` — the one + // direction a cached "this chain resolves nothing" verdict must not + // survive (#10696). + let was_deleted = CLASS_DELETED_KEYS.with(|m| { + m.borrow_mut() + .get_mut(&class_id) + .is_some_and(|keys| keys.remove(name)) + }); + if was_deleted { super::class_lookup_surface_gen_bump(); } - CLASS_DYNAMIC_PROPS.with(|m| { - let created = m - .borrow_mut() - .entry(class_id) - .or_default() - .insert(name.to_string(), value) - .is_none(); - if created { - crate::object::CLASS_DYNAMIC_PROP_ORDER.with(|order| { - order - .borrow_mut() - .entry(class_id) - .or_default() - .push(name.to_string()); - }); - } - }); - crate::gc::runtime_write_barrier_root_nanbox(value.to_bits()); + // The class function object's own-property bag (barriered, traced). + crate::object::class_value::class_static_set(class_id, name, value); } /// Associate a declared static field's runtime-table entry with the LLVM @@ -191,11 +156,7 @@ pub(crate) fn class_ref_dynamic_prop_root_store(class_id: u32, name: &str, value /// constructor ref so `verifyProperty(C, "field", …)` sees a real data /// descriptor (test262 class/elements static-field-declaration & friends). pub(crate) fn class_own_static_field_value(class_id: u32, name: &str) -> Option { - CLASS_DYNAMIC_PROPS.with(|m| { - m.borrow() - .get(&class_id) - .and_then(|props| props.get(name).copied()) - }) + crate::object::class_value::class_static_get(class_id, name) } /// Enumerable own string keys of a class constructor: the static fields (and @@ -216,27 +177,10 @@ pub(crate) fn class_own_enumerable_field_names(class_id: u32) -> Vec { } pub(crate) fn class_own_dynamic_prop_names(class_id: u32) -> Vec { - let mut names = crate::object::CLASS_DYNAMIC_PROP_ORDER - .with(|order| order.borrow().get(&class_id).cloned().unwrap_or_default()); - CLASS_DYNAMIC_PROPS.with(|props| { - let props = props.borrow(); - let Some(props) = props.get(&class_id) else { - names.clear(); - return; - }; - names.retain(|name| props.contains_key(name)); - // Registries populated by older/native paths may predate the order - // side table. Keep those visible with a deterministic fallback. - let mut missing: Vec = props - .keys() - .filter(|name| !names.contains(name)) - .cloned() - .collect(); - missing.sort(); - names.extend(missing); - }); - names.retain(|key| !crate::object::is_internal_runtime_key(key)); - names + crate::object::class_value::class_static_entries(class_id) + .into_iter() + .map(|(name, _)| name) + .collect() } /// #7190: record a `defineProperty`-installed static key's attributes. Called @@ -274,25 +218,11 @@ pub(crate) fn class_static_key_is_non_enumerable(class_id: u32, name: &str) -> b /// only — does not read the value, so it never invokes a static getter. Used by /// the `in` operator on a class ref (#6149). pub(crate) fn class_has_own_dynamic_prop(class_id: u32, name: &str) -> bool { - CLASS_DYNAMIC_PROPS.with(|m| { - m.borrow() - .get(&class_id) - .map(|props| props.contains_key(name)) - .unwrap_or(false) - }) + crate::object::class_value::class_static_get(class_id, name).is_some() } pub(crate) fn class_delete_own_dynamic_prop(class_id: u32, name: &str) { - CLASS_DYNAMIC_PROPS.with(|m| { - if let Some(props) = m.borrow_mut().get_mut(&class_id) { - props.remove(name); - } - }); - crate::object::CLASS_DYNAMIC_PROP_ORDER.with(|order| { - if let Some(names) = order.borrow_mut().get_mut(&class_id) { - names.retain(|existing| existing != name); - } - }); + crate::object::class_value::class_static_remove(class_id, name); } pub(crate) fn class_prototype_method_value_cache_root_store( diff --git a/crates/perry-runtime/src/object/class_value.rs b/crates/perry-runtime/src/object/class_value.rs index 59735ba76a..f7354f9c4d 100644 --- a/crates/perry-runtime/src/object/class_value.rs +++ b/crates/perry-runtime/src/object/class_value.rs @@ -253,6 +253,69 @@ pub(crate) fn scan_class_value_roots_mut(visitor: &mut crate::gc::RuntimeRootVis }); } +// --------------------------------------------------------------------------- +// Statics: the class function object's OWN properties. +// --------------------------------------------------------------------------- + +/// A runtime-internal static key (private statics, computed-key records, +/// class captures): stored in the function object's internal state record, +/// never as a property. +#[inline] +fn is_internal_static_key(name: &str) -> bool { + crate::object::is_internal_runtime_key(name) +} + +/// Class `class_id`'s own static data property `name` (a declared static +/// field or a runtime `C.x = v`): a slot of its function object's own-property +/// bag. +pub(crate) fn class_static_get(class_id: u32, name: &str) -> Option { + let ptr = class_value_ptr(class_id) as usize; + // SAFETY: `class_value_ptr` returns this agent's live class closure. + unsafe { + if is_internal_static_key(name) { + crate::closure::props::state_internal_get(ptr, name) + } else { + crate::closure::props::bag_get(ptr, name.as_bytes()) + } + } +} + +/// Define/overwrite class `class_id`'s own static data property `name`. +pub(crate) fn class_static_set(class_id: u32, name: &str, value: f64) { + let ptr = class_value_ptr(class_id) as usize; + // SAFETY: as above; the bag writers run under a GcSuppressScope. + unsafe { + if is_internal_static_key(name) { + crate::closure::props::state_internal_set(ptr, name, value); + } else { + crate::closure::props::bag_set(ptr, name, value); + } + } +} + +/// Remove class `class_id`'s own static data property `name`; true when it +/// existed. +pub(crate) fn class_static_remove(class_id: u32, name: &str) -> bool { + let ptr = class_value_ptr(class_id) as usize; + // SAFETY: as above. + unsafe { + if is_internal_static_key(name) { + crate::closure::props::state_internal_remove(ptr, name) + } else { + crate::closure::props::bag_remove(ptr, name) + } + } +} + +/// Class `class_id`'s own static data properties in own-key order (integer +/// keys ascending, then creation order). Internal keys are not properties and +/// never appear. +pub(crate) fn class_static_entries(class_id: u32) -> Vec<(String, f64)> { + let ptr = class_value_ptr(class_id) as usize; + // SAFETY: as above. + unsafe { crate::closure::props::bag_snapshot(ptr) } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index 00d56a5fc8..8c4bafdda7 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -1420,11 +1420,7 @@ pub(crate) fn get_field_by_name_past_inherited_cache( if super::super::class_registry::class_is_key_deleted(class_id, name) { return JSValue::undefined(); } - let result = CLASS_DYNAMIC_PROPS.with(|m| { - m.borrow() - .get(&class_id) - .and_then(|props| props.get(name).copied()) - }); + let result = crate::object::class_value::class_static_get(class_id, name); if let Some(v) = result { return JSValue::from_bits(v.to_bits()); } @@ -1527,11 +1523,8 @@ pub(crate) fn get_field_by_name_past_inherited_cache( // resolve to undefined. Skip the registry read for the // deleted level and keep walking up. if !super::super::class_registry::class_is_key_deleted(p, name) { - let inherited = CLASS_DYNAMIC_PROPS.with(|m| { - m.borrow() - .get(&p) - .and_then(|props| props.get(name).copied()) - }); + let inherited = + crate::object::class_value::class_static_get(p, name); if let Some(v) = inherited { return JSValue::from_bits(v.to_bits()); } diff --git a/crates/perry-runtime/src/object/field_set_by_name.rs b/crates/perry-runtime/src/object/field_set_by_name.rs index ea1e573a1c..dc7a0e5750 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -452,11 +452,7 @@ pub extern "C" fn js_object_set_field_by_name( .is_some() || super::native_module::class_has_own_method(class_id, &name) } else { - CLASS_DYNAMIC_PROPS.with(|m| { - m.borrow() - .get(&class_id) - .is_some_and(|props| props.contains_key(&name)) - }) + crate::object::class_value::class_static_get(class_id, &name).is_some() }; // `C.prototype[key] = v` where `key` is an instance // accessor invokes the setter with `this = C.prototype`. diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 3b0e851fcd..9b03b15355 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -734,19 +734,6 @@ pub(crate) struct ShapeCacheEntry { } crate::perry_thread_local! { - /// Issue #618-followup / drizzle SQL.Aliased: dynamic properties added - /// via the IIFE pattern `((SQL2) => { SQL2.Aliased = Aliased; })(SQL)` - /// to imported classes (which Perry stores as INT32-tagged class ids). - /// Pre-fix `js_object_set_field_by_name` saw the receiver as an INT32 - /// "small handle" and silently dropped the assignment. Now route through - /// this side-table keyed by class_id. - pub(crate) static CLASS_DYNAMIC_PROPS: std::cell::RefCell>> = - std::cell::RefCell::new(std::collections::HashMap::new()); - /// Property-creation order for `CLASS_DYNAMIC_PROPS`. The value table is a - /// HashMap for hot lookup, while [[OwnPropertyKeys]] needs first-insertion - /// order (with delete + re-add moving a key to the end). - pub(crate) static CLASS_DYNAMIC_PROP_ORDER: std::cell::RefCell>> = - std::cell::RefCell::new(std::collections::HashMap::new()); /// #7190: `(writable, enumerable)` for static own keys installed by /// `Object.defineProperty(C, k, desc)`. They live in `CLASS_DYNAMIC_PROPS` /// next to `static x = …` fields, which are writable AND enumerable by diff --git a/crates/perry-runtime/src/object/native_call_method/common_methods.rs b/crates/perry-runtime/src/object/native_call_method/common_methods.rs index 2c45165e71..f5c532946a 100644 --- a/crates/perry-runtime/src/object/native_call_method/common_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/common_methods.rs @@ -116,14 +116,12 @@ pub(super) unsafe fn dispatch_common( { super::class_registry::class_name_for_id(class_id).is_some() } else { - CLASS_DYNAMIC_PROPS.with(|m| { - m.borrow() - .get(&class_id) - .is_some_and(|props| props.contains_key(key)) - }) || super::class_registry::lookup_static_method_in_chain( - class_id, key, - ) - .is_some() + crate::object::class_value::class_static_get(class_id, key) + .is_some() + || super::class_registry::lookup_static_method_in_chain( + class_id, key, + ) + .is_some() } }) .unwrap_or(false); diff --git a/crates/perry-runtime/src/object/object_ops/has_own.rs b/crates/perry-runtime/src/object/object_ops/has_own.rs index e3effbf465..3d355e0f84 100644 --- a/crates/perry-runtime/src/object/object_ops/has_own.rs +++ b/crates/perry-runtime/src/object/object_ops/has_own.rs @@ -233,11 +233,8 @@ pub extern "C" fn js_object_has_own(obj_value: f64, key_value: f64) -> f64 { { super::super::class_registry::class_name_for_id(class_id).is_some() } else { - let has_public_data = CLASS_DYNAMIC_PROPS.with(|m| { - m.borrow() - .get(&class_id) - .is_some_and(|props| props.contains_key(key)) - }); + let has_public_data = + crate::object::class_value::class_static_get(class_id, key).is_some(); has_public_data || (!key.starts_with('#') && (super::super::class_registry::lookup_static_method_in_chain( diff --git a/crates/perry-runtime/src/object/property_key.rs b/crates/perry-runtime/src/object/property_key.rs index 1c71c8da4c..da68b8c1c3 100644 --- a/crates/perry-runtime/src/object/property_key.rs +++ b/crates/perry-runtime/src/object/property_key.rs @@ -413,9 +413,7 @@ pub unsafe extern "C" fn js_super_accessor_get( let mut cid = parent_class_id; let mut depth = 0usize; while cid != 0 && depth < 32 { - if let Some(v) = crate::object::CLASS_DYNAMIC_PROPS - .with(|m| m.borrow().get(&cid).and_then(|f| f.get(key_name)).copied()) - { + if let Some(v) = crate::object::class_value::class_static_get(cid, key_name) { return v; } match crate::object::get_parent_class_id(cid) { diff --git a/test-files/test_gap_class_value_statics_own.ts b/test-files/test_gap_class_value_statics_own.ts new file mode 100644 index 0000000000..a7a397698a --- /dev/null +++ b/test-files/test_gap_class_value_statics_own.ts @@ -0,0 +1,11 @@ +// Class statics are the constructor's OWN properties: reflection, spread and +// Object.assign see exactly node's keys (no compiler-internal names). +class A { static s = 1; static #p = 2; static m() { return A.#p; } static { (A as any).boot = "b"; } } +class B extends A { static t = 3; } +console.log(Reflect.ownKeys(A).map(String).sort().join(",")); +console.log(Object.getOwnPropertyNames(B).sort().join(",")); +console.log(Object.keys({ ...(A as any) }).join(","), Object.keys({ ...(B as any) }).join(",")); +console.log(Object.keys(Object.assign({}, A)).join(","), JSON.stringify(Object.assign({}, B))); +console.log(Object.entries(A).map(([k, v]) => k + "=" + v).join(",")); +for (const k in B) console.log("for-in", k); +console.log(A.m()); From 6a5cd0d855181af39236769ce0e03bd124512b0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 26 Sep 2026 14:13:40 +0000 Subject: [PATCH 06/29] fix(runtime): a class function object as a dynamic parent, a bind target and a deep-equal operand Found by the gap suite on the stage-1/2 build (20 class tests): - `super()` to a dynamic parent that is a class (mixins, factory heritage, `extends ns.C`) tested the INT32 tag and fell through to calling the parent's [[Call]] (TypeError "cannot be invoked without 'new'"); it now asks class_value_id and runs the class constructor on `this`. is_self_heritage_value likewise. - `C.bind(x).name` fell back to the thunk's function name ("bound "); the declared name of a class function object is its class's. - util.isDeepStrictEqual(ClassA, ClassB) compared renderings, so two classes named alike were deep-equal; a class is identity-only, like a promise. --- .../src/builtins/formatting/identity_equality.rs | 6 +++++- crates/perry-runtime/src/closure/dispatch/bound.rs | 4 ++++ .../src/object/class_registry/evaluation_heritage.rs | 3 +-- .../perry-runtime/src/object/global_this/fetch_globals.rs | 3 +-- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/perry-runtime/src/builtins/formatting/identity_equality.rs b/crates/perry-runtime/src/builtins/formatting/identity_equality.rs index cd7c6d3073..64782ad3ae 100644 --- a/crates/perry-runtime/src/builtins/formatting/identity_equality.rs +++ b/crates/perry-runtime/src/builtins/formatting/identity_equality.rs @@ -24,5 +24,9 @@ fn is_weak_collection_value(value: f64) -> bool { #[inline] pub(super) fn is_identity_only_deep_equal_value(value: f64) -> bool { - crate::promise::js_value_is_promise(value) != 0 || is_weak_collection_value(value) + crate::promise::js_value_is_promise(value) != 0 + || is_weak_collection_value(value) + // A class is its function object: two distinct classes render alike + // (`[class Twin]`) but are never deep-equal. + || crate::object::class_value::class_value_id(value).is_some() } diff --git a/crates/perry-runtime/src/closure/dispatch/bound.rs b/crates/perry-runtime/src/closure/dispatch/bound.rs index 660189a090..bd10ac03f4 100644 --- a/crates/perry-runtime/src/closure/dispatch/bound.rs +++ b/crates/perry-runtime/src/closure/dispatch/bound.rs @@ -483,6 +483,10 @@ pub(crate) fn rebind_explicit_this(target: f64, this_arg: f64) -> f64 { /// lazily here instead of at bind time is observationally identical. unsafe fn bound_target_declared_name(target_value: f64) -> String { use crate::value::JSValue; + // A class function object's declared name is its class's. + if let Some(class_id) = crate::object::class_value::class_value_id(target_value) { + return crate::object::class_name_for_id(class_id).unwrap_or_default(); + } let target_jv = JSValue::from_bits(target_value.to_bits()); if target_jv.is_pointer() { let target_closure = target_jv.as_pointer::(); diff --git a/crates/perry-runtime/src/object/class_registry/evaluation_heritage.rs b/crates/perry-runtime/src/object/class_registry/evaluation_heritage.rs index 1dc6718a87..82bfdabc25 100644 --- a/crates/perry-runtime/src/object/class_registry/evaluation_heritage.rs +++ b/crates/perry-runtime/src/object/class_registry/evaluation_heritage.rs @@ -148,8 +148,7 @@ pub(crate) fn scan_active_class_evaluations_mut(visitor: &mut crate::gc::Runtime /// class value with its own pinned heritage, and rejecting those would break the /// factory chains that lowering already models correctly. pub(crate) fn is_self_heritage_value(class_id: u32, parent_bits: u64) -> bool { - const INT32_TAG: u64 = 0x7FFE_0000_0000_0000; - parent_bits & 0xFFFF_0000_0000_0000 == INT32_TAG && parent_bits as u32 == class_id + crate::object::class_value::class_value_id_bits(parent_bits) == Some(class_id) } /// #10624: monotone "has any class object ever pinned its own heritage" diff --git a/crates/perry-runtime/src/object/global_this/fetch_globals.rs b/crates/perry-runtime/src/object/global_this/fetch_globals.rs index 488b43b269..c3693ebe4c 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -1037,8 +1037,7 @@ pub unsafe extern "C" fn js_fetch_or_value_super( // base constructor would never run — parent `this. = …` // writes (e.g. `this.nextConfig = opts`) would be lost. Invoke the // class constructor directly on `this` instead. - if bits & TAG_MASK == INT32_TAG { - let parent_cid = bits as u32; + if let Some(parent_cid) = crate::object::class_value::class_value_id(parent_val) { if let Some(obj) = subclass_this_object_ptr(this_box) { return super::super::class_constructors::run_class_constructor_on_this_flat( parent_cid, obj as i64, args_ptr, args_len, From a10b3e95788e4b63b709e639d7359a36f1102771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 26 Sep 2026 14:24:06 +0000 Subject: [PATCH 07/29] perf(runtime): class probes off the hot bind and callable paths js_function_bind asked class_ref_id (and the legacy word) for every target before learning it was a closure; value_is_callable asked class_ref_id before the closure probe. A pointer target is a closure or a native handle (a class function object is a closure), so only a non-pointer target pays the class probe now. class_ref_id / class_prototype_ref_id are #[inline]: the method-bind path calls them per bind with an instance receiver, which the pre-filter rejects on the ShapeId word. --- .../src/closure/dispatch/bound.rs | 21 ++++++++++++------- crates/perry-runtime/src/object/instanceof.rs | 6 ++---- .../object/native_module/class_ref_values.rs | 2 ++ 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/crates/perry-runtime/src/closure/dispatch/bound.rs b/crates/perry-runtime/src/closure/dispatch/bound.rs index bd10ac03f4..e459659281 100644 --- a/crates/perry-runtime/src/closure/dispatch/bound.rs +++ b/crates/perry-runtime/src/closure/dispatch/bound.rs @@ -619,12 +619,9 @@ pub unsafe extern "C" fn js_function_bind( let err = crate::error::js_typeerror_new(msg); crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64)); } - let target_class_id = crate::object::class_ref_id(target_value).or_else(|| { - crate::object::class_prototype_ref_id(target_value) - .is_none() - .then(|| crate::object::class_value::legacy_class_value_word(target_value.to_bits())) - .flatten() - }); + // A pointer target is a closure (a class function object is one) or a + // callable native handle; only a non-pointer target can be the legacy + // INT32 class form, so only it pays the class probe. let target_is_closure = if target_jv.is_pointer() { let ptr = target_jv.as_pointer::(); if ptr.is_null() || !is_closure_ptr(ptr as usize) { @@ -633,7 +630,17 @@ pub unsafe extern "C" fn js_function_bind( return target_value; } true - } else if target_class_id.is_some() { + } else if crate::object::class_ref_id(target_value) + .or_else(|| { + crate::object::class_prototype_ref_id(target_value) + .is_none() + .then(|| { + crate::object::class_value::legacy_class_value_word(target_value.to_bits()) + }) + .flatten() + }) + .is_some() + { // ClassRefs are callable/constructable INT32-tagged values rather // than heap closures. They still need a real BoundFunction wrapper // so `new C.bind(_, ...args)()` prepends its captured arguments. diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index 7b8c44d60a..4c573cde06 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -44,13 +44,11 @@ pub(crate) fn value_is_callable(value: f64) -> bool { // user-crafted NaN payload sharing this tag band (e.g. via // `DataView.setFloat64` — a real JS number, not a class ref) is not // misclassified as callable. - if class_ref_id(value).is_some() { - return true; - } let jv = crate::JSValue::from_bits(value.to_bits()); if !jv.is_pointer() { - return false; + return class_ref_id(value).is_some(); } + // A class function object is a closure: one probe answers both. crate::closure::is_closure_ptr((jv.bits() & crate::value::POINTER_MASK) as usize) } diff --git a/crates/perry-runtime/src/object/native_module/class_ref_values.rs b/crates/perry-runtime/src/object/native_module/class_ref_values.rs index fc08b0d8c5..23583906ef 100644 --- a/crates/perry-runtime/src/object/native_module/class_ref_values.rs +++ b/crates/perry-runtime/src/object/native_module/class_ref_values.rs @@ -28,6 +28,7 @@ pub(crate) fn class_prototype_ref_value(class_id: u32) -> f64 { ) } +#[inline] pub(crate) fn class_prototype_ref_id(value: f64) -> Option { let bits = value.to_bits(); if (bits >> 48) == 0x7FFE && (bits & CLASS_PROTOTYPE_REF_FLAG) != 0 { @@ -42,6 +43,7 @@ pub(crate) fn class_prototype_ref_id(value: f64) -> Option { /// A class constructor OR its `C.prototype` reference -> the class id. The /// constructor half is [`super::class_value::class_value_id`] (both forms); /// callers that mean only the constructor ask that directly. +#[inline] pub(crate) fn class_ref_id(value: f64) -> Option { super::class_value::class_value_id(value).or_else(|| class_prototype_ref_id(value)) } From 888124beb54f7dc66eaa6c462af37e18a28307c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 26 Sep 2026 14:48:49 +0000 Subject: [PATCH 08/29] chore: root-holder inventory for the class-value table and the deleted static tables CLASS_DYNAMIC_PROPS and CLASS_DYNAMIC_PROP_ORDER are gone (statics live in the class function object's bag); PASS1_MARKED's gc/mod.rs pin is re-audited for the one added reg_scanner! registration (the class-value table). --- scripts/gc_runtime_root_holders.json | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 9c29432a14..2ab0ac3b9b 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -80,7 +80,7 @@ "file": "crates/perry-ext-http/src/server/server.rs", "name": "PENDING_CONNECTION_EVENTS", "verdict": "not_a_gc_pointer", - "why": "Vec of (server_handle, socket_handle) registry-id pairs drained by js_node_http_server_process_pending to fire 'connection' listeners; socket_handle is a connection-socket IncomingMessage handle (alloc_connection_socket). Both are perry-ffi registry ids, not NaN-boxed values — the listeners themselves are in HttpServer.listeners, scanned by scan_http_server_roots, and the socket's own listeners/signal fields are scanned via iter_handles_of_mut::." + "why": "Vec of (server_handle, socket_handle) registry-id pairs drained by js_node_http_server_process_pending to fire 'connection' listeners; socket_handle is a connection-socket IncomingMessage handle (alloc_connection_socket). Both are perry-ffi registry ids, not NaN-boxed values \u2014 the listeners themselves are in HttpServer.listeners, scanned by scan_http_server_roots, and the socket's own listeners/signal fields are scanned via iter_handles_of_mut::." }, { "file": "crates/perry-ext-http/src/server/server.rs", @@ -386,7 +386,7 @@ "file": "crates/perry-runtime/src/gc/census.rs", "name": "PASS1_MARKED", "verdict": "non_moving_snapshot", - "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module \u2014 all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete \u2192 sweep-entry window of a synchronous full \u2014 where PASS1_MARKED is populated and consumed within one `run_to_completion` \u2014 is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize \u2014 INSIDE the window \u2014 the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes \u2014 in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved. Re-audited 2026-09-15 for turnloop P0, which touched `gc/mod.rs` with one added call: `crate::event_pump::shutdown_wait_driver()` inside `js_gc_release_current_thread_collection_side_allocations`, the process-exit funnel. That function runs once no more JavaScript can run on the thread, never from inside a collection cycle; the added call drops the thread's turnloop wait loop (closing its kqueue/epoll descriptor) and may print a diagnostic line. It allocates no GC object, relocates nothing, starts no collection and runs no JS callback. The census boundaries and the mark-complete -> sweep-entry window are untouched.. Re-audited 2026-09-16 for the copying minor's per-parent weak-holder fact: `gc/mod.rs` gains exactly one line, `mod copying_parent_facts;`, a module declaration. The module it declares holds `weak_holder_fact` (a read of the parent's `obj_type`/`class_id` via `weakref::is_weak_holder_header`) and the copying minor's `visit_slot_with_parent`, moved verbatim out of `gc/copying.rs` for the 2000-line lint. Both run only inside a COPYING MINOR, which skips both census boundaries (`census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` are synchronous-full only). Nothing was added to any full-cycle phase, and the declaration itself executes no code. Neither boundary moved and the synchronous mark-complete to sweep-entry window gains no allocation, relocation, collection or JS callback. Re-audited 2026-09-18 for the #10532 follow-up argument-list rooting fix, which touched `gc/mod.rs`. The only change there is `mod collection_points;` plus a `pub(crate) use collection_points::collection_point;` re-export (and, under `#[cfg(test)]`, `arm_collection_point`). `collection_point` is an inline no-op outside `cfg(test)`; under test it only runs a copying minor when called from ordinary MUTATOR code (`proxy.rs`'s `Reflect.apply` rebind path and `registry.rs`'s rest-array bundler), never from inside `step_mark_propagation` or `step_sweep`. Neither `census_pass1_if_armed` nor `census_take_if_armed_at_full_sweep_start` is reachable from it, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-18 (same PR, round 2) for the added `arm_collection_point_after` re-export in `gc/mod.rs`: another pure re-export line, same as the `collection_point`/`arm_collection_point` one already covered above. `arm_collection_point_after` only changes test-only arming state in `collection_points.rs` (which named site fires and on which hit); it still runs no mark/sweep control flow. Re-audited 2026-09-19 for #10735 (require.main threading): gc/mod.rs gains exactly one line, `reg_scanner!(crate::module_require::scan_cjs_main_module_root_mut);`, registering the new CJS_MAIN_MODULE thread-local's mutable-root scanner beside the existing `scan_module_path_roots_mut` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it runs during root scanning, before mark propagation completes, and does not execute between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start`. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-20 for #10834 (inherited-property read cache). `gc/mod.rs` gains exactly one line: `reg_scanner!(crate::object::inherited_read_cache::scan_inherited_read_cache_roots_mut);` in `gc_init()`. A scanner registration adds a root SOURCE for the mutable-root walks. The walk runs inside `RootScanCycleState::step_current_subphase`, i.e. entirely within the RootScan phase: `step_root_scan` only sets `self.phase = GcCyclePhase::MarkPropagation` once that loop reports done (`gc/cycle.rs:958-961`), and `census_pass1_if_armed()` fires at the END of `step_mark_propagation` (`gc/cycle.rs:982`). The scanner therefore runs strictly BEFORE the window opens and can never execute between the boundaries. Its body is a bounded walk of a fixed 512-entry thread-local array calling `visit_tagged_usize_slot` / `visit_usize_slot`; it allocates nothing, relocates nothing and runs no JS callback. Same shape as #9769, #9976/#9977, #10054, #10055 and #10735, all previously cleared. The PR also adds an `INHERITED_READ_CACHE` entry to `DEAD_KEY_PRUNES` in `gc/dead_owner.rs` (not a pinned source). That registry is consumed by `IncrementalSweepState::with_dead_collection_finalize` at `gc/cycle.rs:1548`, which is AFTER `census_take_if_armed_at_full_sweep_start` at `gc/cycle.rs:1505` has already `take()`n the snapshot out of the thread-local -- the same argument that cleared #9845's `collect_dead_registered_regexps_post_trace`. The prune reads addresses and zeroes entries; no GC allocation, relocation or callback. Both additions sit outside the window, on opposite sides of it. Neither boundary moved and the synchronous mark-complete to sweep-entry interval is unchanged. Re-audited 2026-09-22 for #10399 (per-thread module init), which touched `gc/mod.rs`. Two hunks, both init-time: a new free function `raise_default_thread_stack_floor()` and one call to it at the top of `js_gc_init`, before `enter_current_thread_image`'s successor statements. The function reads `RUST_MIN_STACK` from the environment and, only when it is unset, sets it to 32 MiB so a thread spawned against a multi-megabyte static TLS block still has usable stack (glibc carves static TLS out of the thread's stack mapping). It touches no heap object, allocates no GC object, relocates nothing and runs no JS callback. `js_gc_init` is the first runtime call of a compiled `main`, so it runs once before any cycle exists, and it is not reachable from `step_mark_propagation` or `step_sweep`. Same shape as the 2026-09-11 startup-memory-profile re-audit, which cleared the pre-main allocator-policy constructor in the same function. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-23 (size/runtime-decouple, #11135) after the binary-size branch touched `gc/census.rs`, `gc/mod.rs` and `gc/policy.rs`. census.rs: `census_pass1_if_armed` / `census_take_if_armed_at_full_sweep_start` keep their bodies verbatim, moved into `_impl` functions compiled only with the new `gc-instruments` feature (without it both are empty and `census_path()` is `None`, so nothing is ever armed); the take still empties PASS1_MARKED before `take_census`. gc/mod.rs: `gc_init` gains a startup env check that aborts when an instrument knob is set without the feature, before any cycle exists. gc/policy.rs: env-knob OnceLock caches now initialize through `crate::once_init::get_or_init` (same closures, same values). No mark/sweep control flow between the two census boundaries changed; the window is unchanged. Re-audited for Fetch handle reclamation: cycle.rs only redirects the incomplete-cycle Drop cancellation hook to also cancel the Fetch trace. The full-trace finish hook removes native records and cached slots without allocating GC objects or invoking JS; it cannot relocate the census addresses before sweep entry. Re-audited 2026-09-22 for #10928 (one proportional old-reclaim rule), which touched `gc/policy.rs`. Six hunks. (a) Two new thread-locals, `GC_OLD_RECLAIM_PRE_IN_USE_BYTES` (`Cell`) and `GC_OLD_RECLAIM_BACKOFF_SHIFT` (`Cell`): both are byte/shift COUNTS, neither holds a pointer. (b) `gc_old_reclaim_growth_band_bytes` gains a `Cell` read and a left shift -- pure arithmetic over byte counts. (c) `old_reclaim_pressure_due` loses the #7937 absolute first-crossing arm, splits its pure form out as `old_reclaim_pressure_due_inner`, and calls `note_old_reclaim_cycle_started()` when the answer is true. That predicate is read at TRIGGER decisions only -- the allocation-point `gc_check_trigger` and `gc_budgeted_due_trigger` at safepoints -- i.e. before a cycle starts, never between the boundaries; an allocation inside the window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before this code, the same argument the 2026-09-14 due-check fast-path re-audit made for the same function. Even if it did run there it would be sound: `note_old_reclaim_cycle_started` stores one scalar `Cell` from `pacing_arena_in_use_bytes()` (a read of `arena_live_allocated_bytes`), which allocates no GC object, relocates nothing and runs no JS callback -- the window's contract. (d) `update_old_reclaim_backoff` is called only from `finish_full_old_reclaim_baseline`, which runs from `publish_reclaim_outcome` in the Publish subphase, AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local -- exactly where #9831's store and the medium-parse pacing store already sit. (e) `gc_old_reclaim_debt_bytes` drops the absolute arm it mirrored; it remains pure arithmetic read at debt/trigger decisions. (f) `#[cfg(test)]` seams, absent from production builds. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback between them; the change alters only WHEN a collection is scheduled, never what runs inside one. Neither boundary moved and the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-24 for #10960 (growth-aware old-reclaim backoff), which touched `gc/policy.rs` again. One new thread-local, `GC_OLD_RECLAIM_LAST_POST_IN_USE_BYTES` (`Cell`), a byte COUNT that holds no pointer. It is written only by `update_old_reclaim_backoff`, which runs from `finish_full_old_reclaim_baseline` in the Publish subphase, after `step_sweep` has already taken the snapshot out of the thread-local; the change there is pure integer arithmetic deciding whether to widen the band. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback, and neither window boundary moved. Re-audited 2026-09-24 after the class-capture environment added one `reg_scanner!` registration (`scan_class_env_roots_mut`, visiting each guarded class environment's owner class object) to `gc/mod.rs`: a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-25 after the inherited-access lane touched `gc/mod.rs`: the change is one `reg_scanner!` registration for `object::chain_store::scan_chain_store_roots_mut`, a root scanner that visits store-site chain verdicts (one interned key pointer each) during root scanning. It runs at the start of a cycle, never between mark completion and sweep entry, relocates nothing and runs no JS callback. The synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10698 (allocation-point trigger watermark), which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains `pub(crate) mod trigger_watermark;` and a `pub(crate) use` re-export -- no control flow. In `gc/policy.rs`: (a) `gc_check_trigger` becomes an out-of-line wrapper over an inlined fast path that reads the watermark cell, the malloc registry's length through an unguarded shared borrow and the inline allocator's offset, then either returns or runs the previous body unchanged (`gc_check_trigger_evaluate`). An allocation inside the window reaches it with `GC_FLAG_IN_ALLOC` set: the fast path returns without acting, which is the outcome of the `GC_FLAG_IN_ALLOC` early return it would otherwise reach, and the slow path still takes that early return. (b) The due-trigger evaluation also returns a watermark -- integer arithmetic over values it already read -- published only on the no-action return past every entry guard. (c) The ladder's thresholds and flags are retyped `Cell` -> `TriggerInput`, whose writes add one store retiring the watermark; heap-generation advances and `Arena::set_current` retire it too. (d) `#[cfg(test)]` seams. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback; `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10498 (class-accessor cache), which adds one `reg_scanner!` registration (`scan_class_accessor_cache_roots_mut`, marking and rewriting the cache's key strings) to `gc/mod.rs`: again a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-26 for #10572: `gc/mod.rs` gains one `#[cfg(not(feature = \"hot-diag\"))]` call to `hot_diag::refuse_knobs_without_hot_diag()` in `gc_init`, a startup-only knob check that runs before any cycle and alters no mark/sweep control flow; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-27 for #11416: `gc/mod.rs` loses the `reg_scanner!` registration for `class_accessor_cache::scan_class_accessor_cache_roots_mut` (the cache is deleted); removing a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 for the method-calls lane, which adds one `reg_scanner!` registration (`scan_method_site_roots_mut`, marking and rewriting the method closures inherited method-site entries hold) to `gc/mod.rs`: a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window.", + "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module \u2014 all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete \u2192 sweep-entry window of a synchronous full \u2014 where PASS1_MARKED is populated and consumed within one `run_to_completion` \u2014 is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize \u2014 INSIDE the window \u2014 the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes \u2014 in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved. Re-audited 2026-09-15 for turnloop P0, which touched `gc/mod.rs` with one added call: `crate::event_pump::shutdown_wait_driver()` inside `js_gc_release_current_thread_collection_side_allocations`, the process-exit funnel. That function runs once no more JavaScript can run on the thread, never from inside a collection cycle; the added call drops the thread's turnloop wait loop (closing its kqueue/epoll descriptor) and may print a diagnostic line. It allocates no GC object, relocates nothing, starts no collection and runs no JS callback. The census boundaries and the mark-complete -> sweep-entry window are untouched.. Re-audited 2026-09-16 for the copying minor's per-parent weak-holder fact: `gc/mod.rs` gains exactly one line, `mod copying_parent_facts;`, a module declaration. The module it declares holds `weak_holder_fact` (a read of the parent's `obj_type`/`class_id` via `weakref::is_weak_holder_header`) and the copying minor's `visit_slot_with_parent`, moved verbatim out of `gc/copying.rs` for the 2000-line lint. Both run only inside a COPYING MINOR, which skips both census boundaries (`census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` are synchronous-full only). Nothing was added to any full-cycle phase, and the declaration itself executes no code. Neither boundary moved and the synchronous mark-complete to sweep-entry window gains no allocation, relocation, collection or JS callback. Re-audited 2026-09-18 for the #10532 follow-up argument-list rooting fix, which touched `gc/mod.rs`. The only change there is `mod collection_points;` plus a `pub(crate) use collection_points::collection_point;` re-export (and, under `#[cfg(test)]`, `arm_collection_point`). `collection_point` is an inline no-op outside `cfg(test)`; under test it only runs a copying minor when called from ordinary MUTATOR code (`proxy.rs`'s `Reflect.apply` rebind path and `registry.rs`'s rest-array bundler), never from inside `step_mark_propagation` or `step_sweep`. Neither `census_pass1_if_armed` nor `census_take_if_armed_at_full_sweep_start` is reachable from it, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-18 (same PR, round 2) for the added `arm_collection_point_after` re-export in `gc/mod.rs`: another pure re-export line, same as the `collection_point`/`arm_collection_point` one already covered above. `arm_collection_point_after` only changes test-only arming state in `collection_points.rs` (which named site fires and on which hit); it still runs no mark/sweep control flow. Re-audited 2026-09-19 for #10735 (require.main threading): gc/mod.rs gains exactly one line, `reg_scanner!(crate::module_require::scan_cjs_main_module_root_mut);`, registering the new CJS_MAIN_MODULE thread-local's mutable-root scanner beside the existing `scan_module_path_roots_mut` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it runs during root scanning, before mark propagation completes, and does not execute between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start`. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-20 for #10834 (inherited-property read cache). `gc/mod.rs` gains exactly one line: `reg_scanner!(crate::object::inherited_read_cache::scan_inherited_read_cache_roots_mut);` in `gc_init()`. A scanner registration adds a root SOURCE for the mutable-root walks. The walk runs inside `RootScanCycleState::step_current_subphase`, i.e. entirely within the RootScan phase: `step_root_scan` only sets `self.phase = GcCyclePhase::MarkPropagation` once that loop reports done (`gc/cycle.rs:958-961`), and `census_pass1_if_armed()` fires at the END of `step_mark_propagation` (`gc/cycle.rs:982`). The scanner therefore runs strictly BEFORE the window opens and can never execute between the boundaries. Its body is a bounded walk of a fixed 512-entry thread-local array calling `visit_tagged_usize_slot` / `visit_usize_slot`; it allocates nothing, relocates nothing and runs no JS callback. Same shape as #9769, #9976/#9977, #10054, #10055 and #10735, all previously cleared. The PR also adds an `INHERITED_READ_CACHE` entry to `DEAD_KEY_PRUNES` in `gc/dead_owner.rs` (not a pinned source). That registry is consumed by `IncrementalSweepState::with_dead_collection_finalize` at `gc/cycle.rs:1548`, which is AFTER `census_take_if_armed_at_full_sweep_start` at `gc/cycle.rs:1505` has already `take()`n the snapshot out of the thread-local -- the same argument that cleared #9845's `collect_dead_registered_regexps_post_trace`. The prune reads addresses and zeroes entries; no GC allocation, relocation or callback. Both additions sit outside the window, on opposite sides of it. Neither boundary moved and the synchronous mark-complete to sweep-entry interval is unchanged. Re-audited 2026-09-22 for #10399 (per-thread module init), which touched `gc/mod.rs`. Two hunks, both init-time: a new free function `raise_default_thread_stack_floor()` and one call to it at the top of `js_gc_init`, before `enter_current_thread_image`'s successor statements. The function reads `RUST_MIN_STACK` from the environment and, only when it is unset, sets it to 32 MiB so a thread spawned against a multi-megabyte static TLS block still has usable stack (glibc carves static TLS out of the thread's stack mapping). It touches no heap object, allocates no GC object, relocates nothing and runs no JS callback. `js_gc_init` is the first runtime call of a compiled `main`, so it runs once before any cycle exists, and it is not reachable from `step_mark_propagation` or `step_sweep`. Same shape as the 2026-09-11 startup-memory-profile re-audit, which cleared the pre-main allocator-policy constructor in the same function. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-23 (size/runtime-decouple, #11135) after the binary-size branch touched `gc/census.rs`, `gc/mod.rs` and `gc/policy.rs`. census.rs: `census_pass1_if_armed` / `census_take_if_armed_at_full_sweep_start` keep their bodies verbatim, moved into `_impl` functions compiled only with the new `gc-instruments` feature (without it both are empty and `census_path()` is `None`, so nothing is ever armed); the take still empties PASS1_MARKED before `take_census`. gc/mod.rs: `gc_init` gains a startup env check that aborts when an instrument knob is set without the feature, before any cycle exists. gc/policy.rs: env-knob OnceLock caches now initialize through `crate::once_init::get_or_init` (same closures, same values). No mark/sweep control flow between the two census boundaries changed; the window is unchanged. Re-audited for Fetch handle reclamation: cycle.rs only redirects the incomplete-cycle Drop cancellation hook to also cancel the Fetch trace. The full-trace finish hook removes native records and cached slots without allocating GC objects or invoking JS; it cannot relocate the census addresses before sweep entry. Re-audited 2026-09-22 for #10928 (one proportional old-reclaim rule), which touched `gc/policy.rs`. Six hunks. (a) Two new thread-locals, `GC_OLD_RECLAIM_PRE_IN_USE_BYTES` (`Cell`) and `GC_OLD_RECLAIM_BACKOFF_SHIFT` (`Cell`): both are byte/shift COUNTS, neither holds a pointer. (b) `gc_old_reclaim_growth_band_bytes` gains a `Cell` read and a left shift -- pure arithmetic over byte counts. (c) `old_reclaim_pressure_due` loses the #7937 absolute first-crossing arm, splits its pure form out as `old_reclaim_pressure_due_inner`, and calls `note_old_reclaim_cycle_started()` when the answer is true. That predicate is read at TRIGGER decisions only -- the allocation-point `gc_check_trigger` and `gc_budgeted_due_trigger` at safepoints -- i.e. before a cycle starts, never between the boundaries; an allocation inside the window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before this code, the same argument the 2026-09-14 due-check fast-path re-audit made for the same function. Even if it did run there it would be sound: `note_old_reclaim_cycle_started` stores one scalar `Cell` from `pacing_arena_in_use_bytes()` (a read of `arena_live_allocated_bytes`), which allocates no GC object, relocates nothing and runs no JS callback -- the window's contract. (d) `update_old_reclaim_backoff` is called only from `finish_full_old_reclaim_baseline`, which runs from `publish_reclaim_outcome` in the Publish subphase, AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local -- exactly where #9831's store and the medium-parse pacing store already sit. (e) `gc_old_reclaim_debt_bytes` drops the absolute arm it mirrored; it remains pure arithmetic read at debt/trigger decisions. (f) `#[cfg(test)]` seams, absent from production builds. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback between them; the change alters only WHEN a collection is scheduled, never what runs inside one. Neither boundary moved and the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-24 for #10960 (growth-aware old-reclaim backoff), which touched `gc/policy.rs` again. One new thread-local, `GC_OLD_RECLAIM_LAST_POST_IN_USE_BYTES` (`Cell`), a byte COUNT that holds no pointer. It is written only by `update_old_reclaim_backoff`, which runs from `finish_full_old_reclaim_baseline` in the Publish subphase, after `step_sweep` has already taken the snapshot out of the thread-local; the change there is pure integer arithmetic deciding whether to widen the band. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback, and neither window boundary moved. Re-audited 2026-09-24 after the class-capture environment added one `reg_scanner!` registration (`scan_class_env_roots_mut`, visiting each guarded class environment's owner class object) to `gc/mod.rs`: a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-25 after the inherited-access lane touched `gc/mod.rs`: the change is one `reg_scanner!` registration for `object::chain_store::scan_chain_store_roots_mut`, a root scanner that visits store-site chain verdicts (one interned key pointer each) during root scanning. It runs at the start of a cycle, never between mark completion and sweep entry, relocates nothing and runs no JS callback. The synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10698 (allocation-point trigger watermark), which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains `pub(crate) mod trigger_watermark;` and a `pub(crate) use` re-export -- no control flow. In `gc/policy.rs`: (a) `gc_check_trigger` becomes an out-of-line wrapper over an inlined fast path that reads the watermark cell, the malloc registry's length through an unguarded shared borrow and the inline allocator's offset, then either returns or runs the previous body unchanged (`gc_check_trigger_evaluate`). An allocation inside the window reaches it with `GC_FLAG_IN_ALLOC` set: the fast path returns without acting, which is the outcome of the `GC_FLAG_IN_ALLOC` early return it would otherwise reach, and the slow path still takes that early return. (b) The due-trigger evaluation also returns a watermark -- integer arithmetic over values it already read -- published only on the no-action return past every entry guard. (c) The ladder's thresholds and flags are retyped `Cell` -> `TriggerInput`, whose writes add one store retiring the watermark; heap-generation advances and `Arena::set_current` retire it too. (d) `#[cfg(test)]` seams. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback; `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10498 (class-accessor cache), which adds one `reg_scanner!` registration (`scan_class_accessor_cache_roots_mut`, marking and rewriting the cache's key strings) to `gc/mod.rs`: again a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-26 for #10572: `gc/mod.rs` gains one `#[cfg(not(feature = \"hot-diag\"))]` call to `hot_diag::refuse_knobs_without_hot_diag()` in `gc_init`, a startup-only knob check that runs before any cycle and alters no mark/sweep control flow; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-27 for #11416: `gc/mod.rs` loses the `reg_scanner!` registration for `class_accessor_cache::scan_class_accessor_cache_roots_mut` (the cache is deleted); removing a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 for the method-calls lane, which adds one `reg_scanner!` registration (`scan_method_site_roots_mut`, marking and rewriting the method closures inherited method-site entries hold) to `gc/mod.rs`: a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 (class constructors as function objects, #11414): `gc/mod.rs` gains one `reg_scanner!` registration (`object::class_value::scan_class_value_roots_mut`, the per-agent class function-object table, visited and rewritten) \u2014 a root-scanner registration that alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -930,12 +930,6 @@ "scanner": "gc::roots GLOBAL_ROOTS \u2014 the cell's address is registered with js_gc_register_global_root (fetch_globals.rs, js_module_top_this)", "why": "Same shape as THREAD_GLOBAL_THIS: a NaN-boxed cache slot registered as a mutable global root at first population." }, - { - "file": "crates/perry-runtime/src/object/mod.rs", - "name": "CLASS_DYNAMIC_PROP_ORDER", - "verdict": "not_a_gc_pointer", - "why": "First-insertion order for CLASS_DYNAMIC_PROPS: HashMap> of owned Rust strings, needed because the value table is a HashMap while [[OwnPropertyKeys]] needs order. Holds no JSValues; the f64 values live in CLASS_DYNAMIC_PROPS, which is already a scanned root." - }, { "file": "crates/perry-runtime/src/object/mod.rs", "name": "TRANSITION_CACHE_YOUNG", @@ -2729,7 +2723,7 @@ "file": "crates/perry-ext-http/src/server/turnloop_serve/conn.rs", "name": "CLOSED", "verdict": "not_a_gc_pointer", - "why": "Vec of connection-socket handle ids (alloc_connection_socket) whose TCP connection fully closed, queued by note_closed_socket/on_closed and drained by the pump — same shape and same file as the pre-existing ABORTED/aborted() request-handle queue this crate already uses for 'aborted'. A registry id, not a NaN-boxed value: the socket's own listeners/signal fields are scanned via iter_handles_of_mut:: in server/mod.rs's scan_http_server_roots." + "why": "Vec of connection-socket handle ids (alloc_connection_socket) whose TCP connection fully closed, queued by note_closed_socket/on_closed and drained by the pump \u2014 same shape and same file as the pre-existing ABORTED/aborted() request-handle queue this crate already uses for 'aborted'. A registry id, not a NaN-boxed value: the socket's own listeners/signal fields are scanned via iter_handles_of_mut:: in server/mod.rs's scan_http_server_roots." }, { "file": "crates/perry-runtime/src/object/field_get_set/ic_miss/private_guard_fast.rs", @@ -3799,10 +3793,6 @@ "file": "crates/perry-runtime/src/object/mod.rs", "name": "ASYNC_GENERATOR_PROTOTYPE_PTR_SLOT" }, - { - "file": "crates/perry-runtime/src/object/mod.rs", - "name": "CLASS_DYNAMIC_PROPS" - }, { "file": "crates/perry-runtime/src/object/mod.rs", "name": "CLASS_PROTOTYPE_METHOD_VALUES" From 1aed5f514a1f706e2f1cdb5e66fd210cecce2522 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 26 Sep 2026 15:10:41 +0000 Subject: [PATCH 09/29] perf(runtime,codegen): a class function object reaches the class lookup first A class held in `any` paid the plain-function walk before the class lookup: `C.s` went IC miss -> closure_get_dynamic_prop's Function.prototype fallbacks (allocating builtin-name strings) -> the object tail -> the class branch, and `C.m()` walked the instance/native/own-override arms of js_native_call_method first (per op, LTO-off builds: static_get 1608 -> 17229, dyn_static_call 3066 -> 32K). - The class branch of the generic read is now class_value_get_field; the generic read, closure_dynamic_prop_by_key (the IC-miss closure arm) and js_native_call_method route a class function object to it / to the class arm first (one ShapeId-word pre-filter for every other receiver). - The per-agent class table is a borrow-free page directory (TLS read, bounds check, two loads). - A static method's prologue caches its class's function object in its own zero-initialised global via js_static_this_resolve_class(cid, slot). Per op now (instr/op, base -> this, LTO-off): static_get 1608 -> 1572, dyn_static_call 3066 -> 3190, static_call 33 -> 46, dyn_new 8580 -> 8560, ctor_eq 4391 -> 4487, instanceof 470 -> 472, map_get 1071 -> 1124, class_value 24.5 -> 33.5. --- .../src/codegen/method_static.rs | 17 +- .../runtime_decls/stdlib_ffi/language_core.rs | 2 +- .../perry-runtime/src/object/class_value.rs | 91 +- .../object/field_get_set/get_field_by_name.rs | 792 +++++++++--------- .../src/object/field_get_set/has_property.rs | 12 + .../src/object/native_call_method.rs | 25 + .../perry-runtime/src/object/this_binding.rs | 27 +- 7 files changed, 536 insertions(+), 430 deletions(-) diff --git a/crates/perry-codegen/src/codegen/method_static.rs b/crates/perry-codegen/src/codegen/method_static.rs index 077dcebf63..ba9c500e6b 100644 --- a/crates/perry-codegen/src/codegen/method_static.rs +++ b/crates/perry-codegen/src/codegen/method_static.rs @@ -41,6 +41,18 @@ pub(in crate::codegen) fn compile_static_method( let ic_base = llmod.ic_counter; let buffer_alias_base = llmod.buffer_alias_counter; + // The prologue's cache of this class's function object (see + // `js_static_this_resolve_class`); thread-local when workers exist, so + // each agent caches its own. + let class_value_slot = format!("@{llvm_name}__classval"); + llmod.add_raw_global(format!( + "{class_value_slot} = private {}global double 0.0, align 8", + if crate::codegen::program_has_worker() { + "thread_local " + } else { + "" + } + )); let lf = llmod.define_function(&llvm_name, DOUBLE, params); // gh #6206 / #6081: same shadow-frame emission as compile_method — static @@ -100,7 +112,10 @@ pub(in crate::codegen) fn compile_static_method( let resolved_this = blk.call( DOUBLE, "js_static_this_resolve_class", - &[(I32, &(class_ref_cid as i32).to_string())], + &[ + (I32, &(class_ref_cid as i32).to_string()), + (PTR, &class_value_slot), + ], ); blk.store(DOUBLE, &resolved_this, &this_slot); if crate::codegen::helpers::precise_root_analysis_enabled() { diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs index de00b7bf45..5f6e56a826 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs @@ -398,7 +398,7 @@ pub(crate) fn declare_core(module: &mut LlModule) { // armed by dynamic static dispatch / call/apply, else returns the // lexical class-ref argument. module.declare_function("js_static_this_resolve", DOUBLE, &[DOUBLE]); - module.declare_function("js_static_this_resolve_class", DOUBLE, &[I32]); + module.declare_function("js_static_this_resolve_class", DOUBLE, &[I32, PTR]); module.declare_function("js_static_this_arm_classref", VOID, &[I32]); module.declare_function("js_static_this_arm_value", VOID, &[DOUBLE]); module.declare_function("js_ctor_return_override", DOUBLE, &[DOUBLE, DOUBLE, I32]); diff --git a/crates/perry-runtime/src/object/class_value.rs b/crates/perry-runtime/src/object/class_value.rs index f7354f9c4d..61fe0267cc 100644 --- a/crates/perry-runtime/src/object/class_value.rs +++ b/crates/perry-runtime/src/object/class_value.rs @@ -144,25 +144,64 @@ pub(crate) fn boxed_class_word(bits: u64) -> f64 { const CLASS_VALUE_PAGE_SHIFT: u32 = 8; const CLASS_VALUE_PAGE_LEN: usize = 1 << CLASS_VALUE_PAGE_SHIFT; -type ClassValuePage = Box<[*mut ClosureHeader; CLASS_VALUE_PAGE_LEN]>; +type ClassValuePage = [*mut ClosureHeader; CLASS_VALUE_PAGE_LEN]; crate::perry_thread_local! { - /// This agent's class function objects, indexed by class id. A GC root - /// (rewritten on a move) via [`scan_class_value_roots_mut`]. - static CLASS_VALUES: std::cell::RefCell>> = - const { std::cell::RefCell::new(Vec::new()) }; + /// This agent's class function objects, indexed by class id: a page + /// directory (`pages`, `len` pages) whose pages are leaked for the agent's + /// life. Read without a borrow flag — the hot path is a TLS read, a bounds + /// check and two loads. A GC root (rewritten on a move) via + /// [`scan_class_value_roots_mut`]. + static CLASS_VALUES: std::cell::Cell<(*mut *mut ClassValuePage, usize)> = + const { std::cell::Cell::new((std::ptr::null_mut(), 0)) }; } #[inline] fn class_value_cached(class_id: u32) -> Option<*mut ClosureHeader> { let page = (class_id >> CLASS_VALUE_PAGE_SHIFT) as usize; let index = class_id as usize & (CLASS_VALUE_PAGE_LEN - 1); - CLASS_VALUES.with(|t| { - let t = t.borrow(); - let p = t.get(page)?.as_ref()?; - let c = p[index]; + let (pages, len) = CLASS_VALUES.with(std::cell::Cell::get); + if page >= len { + return None; + } + // SAFETY: `pages` holds `len` page pointers (null or a live leaked page). + unsafe { + let p = *pages.add(page); + if p.is_null() { + return None; + } + let c = (*p)[index]; (!c.is_null()).then_some(c) - }) + } +} + +/// The table slot for `class_id`, growing the directory / minting the page. +fn class_value_slot(class_id: u32) -> *mut *mut ClosureHeader { + let page = (class_id >> CLASS_VALUE_PAGE_SHIFT) as usize; + let index = class_id as usize & (CLASS_VALUE_PAGE_LEN - 1); + let (mut pages, mut len) = CLASS_VALUES.with(std::cell::Cell::get); + if page >= len { + let new_len = (page + 1).next_power_of_two().max(4); + let mut dir: Vec<*mut ClassValuePage> = vec![std::ptr::null_mut(); new_len]; + if !pages.is_null() { + // SAFETY: the old directory holds `len` entries. + unsafe { dir[..len].copy_from_slice(std::slice::from_raw_parts(pages, len)) }; + // The old directory is leaked: a concurrent reader on this agent + // cannot exist (single-threaded agent), but the few bytes are not + // worth a free/reuse protocol. + } + pages = Box::leak(dir.into_boxed_slice()).as_mut_ptr(); + len = new_len; + CLASS_VALUES.with(|c| c.set((pages, len))); + } + // SAFETY: `page < len`. + unsafe { + let slot = pages.add(page); + if (*slot).is_null() { + *slot = Box::leak(Box::new([std::ptr::null_mut(); CLASS_VALUE_PAGE_LEN])); + } + (**slot).as_mut_ptr().add(index) + } } /// Allocate the class function object for `class_id`: a closure born in the @@ -199,16 +238,8 @@ fn class_value_mint(class_id: u32) -> *mut ClosureHeader { // metadata and weak tables compare it), so no collector may move it. crate::gc::pin_user_ptr_non_young(ptr as *mut u8); } - let page = (class_id >> CLASS_VALUE_PAGE_SHIFT) as usize; - let index = class_id as usize & (CLASS_VALUE_PAGE_LEN - 1); - CLASS_VALUES.with(|t| { - let mut t = t.borrow_mut(); - if t.len() <= page { - t.resize_with(page + 1, || None); - } - t[page].get_or_insert_with(|| Box::new([std::ptr::null_mut(); CLASS_VALUE_PAGE_LEN])) - [index] = ptr; - }); + // SAFETY: the slot is this agent's table entry for `class_id`. + unsafe { *class_value_slot(class_id) = ptr }; crate::gc::runtime_write_barrier_root_heap_word(ptr as u64); ptr } @@ -241,16 +272,20 @@ pub extern "C" fn js_class_value(class_id: i32) -> f64 { /// GC root scan for [`CLASS_VALUES`]; registered in `gc::mod`'s runtime /// scanner list. pub(crate) fn scan_class_value_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { - CLASS_VALUES.with(|t| { - let mut t = t.borrow_mut(); - for page in t.iter_mut().flatten() { - for slot in page.iter_mut() { - if !slot.is_null() { - visitor.visit_raw_mut_ptr_slot(slot); - } + let (pages, len) = CLASS_VALUES.with(std::cell::Cell::get); + for i in 0..len { + // SAFETY: `pages` holds `len` page pointers (null or a live page). + let page = unsafe { *pages.add(i) }; + if page.is_null() { + continue; + } + // SAFETY: a live leaked page of this agent. + for slot in unsafe { (*page).iter_mut() } { + if !slot.is_null() { + visitor.visit_raw_mut_ptr_slot(slot); } } - }); + } } // --------------------------------------------------------------------------- diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index 8c4bafdda7..56f1de6e01 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -85,6 +85,395 @@ pub extern "C" fn js_object_get_field_by_name( get_field_by_name_past_inherited_cache(obj, key) } +/// `C.key` for a class constructor value (its function object, or the legacy +/// INT32 immediate / `C.prototype` reference): the class-static lookup. Split +/// out so a class function object is routed here BEFORE the closure arms of +/// the generic read (`get_field_by_name_past_inherited_cache`), which it +/// would otherwise walk end to end first. +#[inline(never)] +pub(crate) fn class_value_get_field( + obj: *const ObjectHeader, + key: *const crate::StringHeader, + bits: u64, + class_id: u32, +) -> JSValue { + let class_value = crate::object::class_value::boxed_class_word(bits); + let is_prototype_ref = super::super::class_prototype_ref_id(class_value).is_some(); + unsafe { + let name_ptr = (key as *const u8).add(std::mem::size_of::()); + let name_len = (*key).byte_len as usize; + let name = + std::str::from_utf8(std::slice::from_raw_parts(name_ptr, name_len)).unwrap_or(""); + // v0.5.752: class_ref.constructor synthesizes back to the + // same class ref so drizzle's + // `Object.getPrototypeOf(value).constructor === Class` chain + // collapses correctly (with v0.5.751's getPrototypeOf + // returning the class ref for instance receivers). Refs + // #420 / #618 followup. + if is_prototype_ref + && name == "constructor" + && class_id != 0 + && class_has_own_method(class_id, name) + { + let value = class_prototype_method_value_for_name(class_id, name); + return JSValue::from_bits(value.to_bits()); + } + if name == "constructor" + && is_prototype_ref + && class_id != 0 + && is_class_id_registered(class_id) + { + let value = if is_prototype_ref { + super::super::class_constructor_ref_value(class_id) + } else { + class_value + }; + return JSValue::from_bits(value.to_bits()); + } + if name == "prototype" + && class_id != 0 + && is_class_id_registered(class_id) + && !is_prototype_ref + { + let value = super::super::class_registry::class_decl_prototype_value(class_id); + if value.to_bits() == crate::value::TAG_UNDEFINED { + let value = super::super::class_prototype_ref_value(class_id); + return JSValue::from_bits(value.to_bits()); + } + return JSValue::from_bits(value.to_bits()); + } + // A capture-carrying class declaration is materialized as a + // heap class object (`ClassExprFresh`). References that were + // lowered before the declaration's runtime binding existed + // (the class constructor itself and earlier helper closures) + // still carry the template `ClassRef`. Runtime additions such + // as `Object.defineProperty(C, "OPEN", { value: 1 })` live on + // the materialized object, so consulting only the template + // tables makes `C.OPEN` undefined in those bodies even though + // the same expression at the declaration site reads `1`. + // + // `CLASS_OBJECT_VALUES` is already the runtime identity used + // by `instance.constructor`. Read that same current + // evaluation first, preserving its own-property and pinned + // static-parent semantics; a miss continues through the + // ordinary ClassRef registry path below. + if !is_prototype_ref { + if let Some(class_object) = + super::super::class_registry::class_object_value_for_cid(class_id) + { + let class_object = JSValue::from_bits(class_object.to_bits()); + if class_object.is_pointer() { + let class_object = class_object.as_pointer::(); + if !class_object.is_null() && class_object as usize != obj as usize { + let value = js_object_get_field_by_name(class_object, key); + if !value.is_undefined() { + return value; + } + } + } + } + } + // Instance (prototype) methods must only resolve when reading + // off the prototype ref (`C.prototype.m`), NOT off the class ref + // itself (`C.m`). In JS a class object does not expose its + // prototype methods as static members: `class C { m(){} }` has + // `C.m === undefined` (the method lives on `C.prototype`). The + // earlier unconditional lookup leaked instance methods onto the + // class ref, so `C.m` returned a (mis-bound) function. This + // broke NestJS interceptor/guard/pipe resolution: its + // `getInterceptorInstance` duck-types `!!metatype.intercept` to + // decide "is this a class or an already-built instance"; a + // truthy `Class.intercept` made it treat the CLASS as the + // instance, so `intercept()` ran with a broken receiver and + // returned `{}`, which rxjs `innerFrom` then rejected. Real + // static methods are resolved below via + // `lookup_static_method_in_chain`. + if is_prototype_ref && class_id != 0 && class_has_own_method(class_id, name) { + let value = class_prototype_method_value_for_name(class_id, name); + return JSValue::from_bits(value.to_bits()); + } + if is_prototype_ref { + // Class accessors are properties of the class prototype + // chain (charter step 3); `this` is the prototype ref. + if let Some((v, _)) = + super::super::class_registry::class_chain_getter_value(class_id, name, || { + class_value + }) + { + return v; + } + return JSValue::undefined(); + } + // Empty-string is a legal static member key (`static get ''()`); + // the `!name.is_empty()` guard below skips it, so resolve a + // static accessor named "" here (Test262 accessor-name-static + // literal-string-empty). + if name.is_empty() { + if let Some(v) = super::super::class_registry::class_static_accessor_getter_value( + class_id, + name, + class_value, + ) { + return JSValue::from_bits(v.to_bits()); + } + } + if !name.is_empty() { + if super::super::class_registry::class_is_key_deleted(class_id, name) { + return JSValue::undefined(); + } + let result = crate::object::class_value::class_static_get(class_id, name); + if let Some(v) = result { + return JSValue::from_bits(v.to_bits()); + } + // Static DATA fields are INHERITED by subclasses, exactly like + // static methods: `class D {}; D.kind = "x"; class G extends D {}` + // makes `G.kind === "x"` (the class-object proto chain + // `G.__proto__ === D` carries statics). The own-field read above + // only consulted `class_id`; walk the parent class_id chain here + // so an inherited static field (or runtime `Parent.x = …` + // assignment — both live in CLASS_DYNAMIC_PROPS) resolves. Static + // METHODS are handled by `lookup_static_method_in_chain` below; + // this covers the data-field case that was returning `undefined` + // (Auth.js sets `SignInError.kind = "signIn"` and reads it off a + // `CredentialsSignin` subclass to pick the sign-in vs error page). + // + // #6530: `name` is an OWN property of every constructor — a + // subclass never inherits its parent's `.name` (spec: + // ClassDefinitionEvaluation installs it per class). Skip the + // chain walk so the #2059 own-name synthesis below answers + // with THIS class's registered name instead of an ancestor's. + if !matches!(name, "name" | "length") { + // Walk the class-object proto chain for an inherited static + // DATA field. At EACH level the class's pinned + // per-evaluation parent OBJECT is consulted BEFORE the + // parent's registry props (`CLASS_DYNAMIC_PROPS`). + // + // #6552: a subclass of a class-EXPRESSION value evaluated + // more than once (`function make(a){return class{static + // ast=a}}`, then `class Number$ extends make(x) {}` / + // `class Widget$ extends make(y) {}`) records THIS + // evaluation's parent object as its static prototype + // (`class_prototype_object`, #1788), but the parent's + // `CLASS_DYNAMIC_PROPS` are keyed by the class-expression + // TEMPLATE id — shared, last-wins across every evaluation. + // Reading the registry entry for such a parent collapses + // sibling subclasses to the LAST `make(...)` (effect Schema: + // `Number$.ast`/`Widget$.ast` both read the last parent's + // `ast`). The pinned object carries this evaluation's own + // edge, so it is authoritative; the registry read remains + // the fallback for a plain declaration parent (#6443: + // Auth.js `SignInError.kind`), which has no pinned object. + let mut child = class_id; + let mut depth = 0usize; + while depth < 32 { + // The constructor's own `[[Prototype]]`, set by + // `Object.setPrototypeOf(Ctor, obj)`. Checked first: + // it is the nearest static-side link, and unlike + // `class_prototype_object` it is never on an + // instance's chain. + let static_proto = super::super::class_registry::class_static_prototype(child); + if !static_proto.is_null() { + // #10911: this walk re-enters with the PARENT as + // the object. It was written when effect's `ast` + // was a static DATA field (see above), where the + // object doesn't matter; effect now makes `ast` a + // static GETTER, and a getter found this way ran + // with `this` === the parent class. Stash the + // class the read started from so the accessor + // binds it (spec OrdinaryGet threads Receiver) -- + // the same device `resolve_proto_chain_field_inner` + // uses for instance getters. + let prev = crate::object::field_get_set::accessor_receiver_override_begin( + class_value, + ); + let v = js_object_get_field_by_name(static_proto as *const _, key); + crate::object::field_get_set::accessor_receiver_override_end(prev); + if !v.is_undefined() { + return v; + } + } + let proto = super::super::class_registry::class_prototype_object(child); + if !proto.is_null() { + let prev = crate::object::field_get_set::accessor_receiver_override_begin( + class_value, + ); + let v = js_object_get_field_by_name(proto as *const _, key); + crate::object::field_get_set::accessor_receiver_override_end(prev); + // Return a value present on the pinned object even + // when it is `null` — a static explicitly set to + // `null` on THIS evaluation is authoritative and + // must not fall through to the last-wins registry + // entry (a sibling evaluation's value). Only + // `undefined` means "absent here", which continues + // the walk to the parent's registry props / a higher + // ancestor. + if !v.is_undefined() { + return v; + } + } + let p = match get_parent_class_id(child) { + Some(p) if p != 0 && p != child => p, + _ => break, + }; + // A key deleted on THIS ancestor is not provided by it, + // but a higher ancestor may still define it — `delete + // Mid.foo` must let `Sub.foo` inherit `Base.foo`, not + // resolve to undefined. Skip the registry read for the + // deleted level and keep walking up. + if !super::super::class_registry::class_is_key_deleted(p, name) { + let inherited = crate::object::class_value::class_static_get(p, name); + if let Some(v) = inherited { + return JSValue::from_bits(v.to_bits()); + } + } + child = p; + depth += 1; + } + } + if super::super::class_registry::lookup_static_method_in_chain(class_id, name).is_some() + { + let heap_name = { + let layout = std::alloc::Layout::from_size_align(name_len.max(1), 1).unwrap(); + let ptr = std::alloc::alloc(layout); + std::ptr::copy_nonoverlapping(name_ptr, ptr, name_len); + ptr + }; + let result = js_class_method_bind(class_value, heap_name, name_len); + return JSValue::from_bits(result.to_bits()); + } + // `class X extends Promise` — a value read of an inherited + // builtin static (`X.resolve`, `X.all`, …) resolves to the + // reified Promise static (so `X.resolve.bind(X)` works). Only + // fires when no user static shadowed it above. + if super::super::promise_parent_in_chain(class_id) + && super::super::promise_static_function_spec(name).is_some() + { + let v = super::super::js_promise_static_function_value(name_ptr, name_len); + if v.to_bits() != crate::value::TAG_UNDEFINED { + return JSValue::from_bits(v.to_bits()); + } + } + if let Some(v) = super::super::class_registry::class_static_accessor_getter_value( + class_id, + name, + class_value, + ) { + return JSValue::from_bits(v.to_bits()); + } + // #1788: a subclass of a class-expression value + // (`class Sub extends make("A") {}`) inherits the parent + // class OBJECT's OWN per-evaluation static fields. The + // parent object was recorded as `class_id`'s static + // prototype at `extends` time; walk that chain (also + // covering multi-level `class Leaf extends Mid {}`). + // #6530: except `name` — an own property of every + // constructor, never inherited; without the guard a + // subclass of a per-evaluation class object reported its + // BASE's synthesized `.name` (bundled zod: + // `z.string().constructor.name` gave "ZodType"). + if !matches!(name, "name" | "length") { + if let Some(v) = + super::super::class_registry::resolve_proto_chain_field(class_id, key) + { + if !v.is_undefined() && !v.is_null() { + return v; + } + } + } + // #36 / #321: the subclass extends a FUNCTION value + // (`class Svc extends Context.Tag(id)<...>() {}`). Read the + // named static off the parent closure — its OWN props + // (`Svc.key` → "Svc") plus, via the closure getter, its + // static prototype (`Svc._op` → "Tag" on TagProto). + // #10210: the edge is keyed by the class that directly + // `extends `, which may be an ANCESTOR of this + // class (`class Flags extends ConfigTag {}` where + // `ConfigTag extends Context.Service()(id)`), so walk the + // parent chain like `super()` dispatch does. + if let Some(closure_ptr) = + super::super::class_registry::parent_closure_in_chain(class_id) + { + let v = crate::closure::closure_get_dynamic_prop(closure_ptr, name); + let vb = JSValue::from_bits(v.to_bits()); + if !vb.is_undefined() && !vb.is_null() { + return vb; + } + } + // #2059: the constructor's built-in `name` own property — + // the class name. Checked last so an explicit static + // `name` member (method/field, handled above) still wins. + // This is what `assert.throws` reads via + // `thrown.constructor.name` to label the thrown error. + if name == "name" + && class_id != 0 + && !super::super::class_registry::class_is_key_deleted(class_id, name) + { + if let Some(cname) = super::super::class_registry::class_name_for_id(class_id) { + let s = crate::string::js_string_from_bytes(cname.as_ptr(), cname.len() as u32); + return JSValue::from_bits(crate::js_nanbox_string(s as i64).to_bits()); + } + } + if name == "length" + && class_id != 0 + && !is_prototype_ref + && !super::super::class_registry::class_is_key_deleted(class_id, name) + { + if let Some(length) = super::super::class_registry::class_length_for_id(class_id) { + return JSValue::number(length as f64); + } + } + // A class constructor is also a Function object. Reify + // inherited Function.prototype methods for value reads + // (`const bind = C.bind`) just as the closure path does; + // the captured ClassRef is accepted by native method + // dispatch and by `js_function_bind`. + if !is_prototype_ref { + if let Some(method) = super::reified_function_method_name(name) { + let value = crate::closure::reify_function_method_value(class_value, method); + return JSValue::from_bits(value.to_bits()); + } + } + // No own static / inherited entry resolved the name. A class + // constructor is a function, so a bare read of `.caller` or + // `.arguments` hits the poison-pill %ThrowTypeError% accessor + // on `Function.prototype` — strict-mode throws (Perry only + // compiles strict code). Placed last so any own static field, + // accessor, or `defineProperty`-installed data prop of that + // name takes precedence. Prototype-refs (`C.prototype`) are + // plain objects and are excluded. + if !is_prototype_ref && matches!(name, "caller" | "arguments") { + crate::fs::validate::throw_type_error_with_code( + "Restricted function property access", + "ERR_INVALID_ARG_TYPE", + ); + } + // #11492: a constructor's chain ends at %Function.prototype%, + // so a user method or expando installed there + // (`Function.prototype.myHelper = fn`) is readable through + // `C.myHelper` exactly as through a closure. + if !is_prototype_ref { + if let Some(v) = + crate::closure::function_prototype_inherited_get(0, name, class_value) + { + return JSValue::from_bits(v.to_bits()); + } + } + } + // The built-in constructor object's `constructor` value is + // inherited from Function.prototype. It is therefore only the + // fallback after own computed fields, static methods, and + // static accessors of the same name have had a chance to win. + if name == "constructor" && class_id != 0 && is_class_id_registered(class_id) { + let constructor = super::super::js_get_global_this_builtin_value( + b"Function".as_ptr(), + b"Function".len(), + ); + return JSValue::from_bits(constructor.to_bits()); + } + } + return JSValue::undefined(); +} + #[cfg(test)] mod primitive_proto_accessor_tests_10648 { use super::*; @@ -187,6 +576,15 @@ pub(crate) fn get_field_by_name_past_inherited_cache( if key.is_null() { return JSValue::undefined(); } + // A class function object (not the legacy immediate, which keeps its + // later arm) is a class constructor: its statics are the class lookup's. + // One ShapeId-word pre-filter for every other receiver. + { + let raw = (obj as u64 & crate::value::POINTER_MASK) as usize; + if let Some(class_id) = crate::object::class_value::class_closure_id(raw) { + return class_value_get_field(obj, key, obj as u64, class_id); + } + } // `process.env` is a live OS-backed exotic object. Direct reads are // codegen-specialized, but an alias (`const env = process.env; env.X`) // reaches this generic path. On Windows the OS lookup also supplies the @@ -1294,399 +1692,7 @@ pub(crate) fn get_field_by_name_past_inherited_cache( crate::object::class_value::legacy_class_ptr_word(bits), key.is_null(), ) { - let class_value = crate::object::class_value::boxed_class_word(bits); - let is_prototype_ref = super::super::class_prototype_ref_id(class_value).is_some(); - unsafe { - let name_ptr = (key as *const u8).add(std::mem::size_of::()); - let name_len = (*key).byte_len as usize; - let name = std::str::from_utf8(std::slice::from_raw_parts(name_ptr, name_len)) - .unwrap_or(""); - // v0.5.752: class_ref.constructor synthesizes back to the - // same class ref so drizzle's - // `Object.getPrototypeOf(value).constructor === Class` chain - // collapses correctly (with v0.5.751's getPrototypeOf - // returning the class ref for instance receivers). Refs - // #420 / #618 followup. - if is_prototype_ref - && name == "constructor" - && class_id != 0 - && class_has_own_method(class_id, name) - { - let value = class_prototype_method_value_for_name(class_id, name); - return JSValue::from_bits(value.to_bits()); - } - if name == "constructor" - && is_prototype_ref - && class_id != 0 - && is_class_id_registered(class_id) - { - let value = if is_prototype_ref { - super::super::class_constructor_ref_value(class_id) - } else { - class_value - }; - return JSValue::from_bits(value.to_bits()); - } - if name == "prototype" - && class_id != 0 - && is_class_id_registered(class_id) - && !is_prototype_ref - { - let value = super::super::class_registry::class_decl_prototype_value(class_id); - if value.to_bits() == crate::value::TAG_UNDEFINED { - let value = super::super::class_prototype_ref_value(class_id); - return JSValue::from_bits(value.to_bits()); - } - return JSValue::from_bits(value.to_bits()); - } - // A capture-carrying class declaration is materialized as a - // heap class object (`ClassExprFresh`). References that were - // lowered before the declaration's runtime binding existed - // (the class constructor itself and earlier helper closures) - // still carry the template `ClassRef`. Runtime additions such - // as `Object.defineProperty(C, "OPEN", { value: 1 })` live on - // the materialized object, so consulting only the template - // tables makes `C.OPEN` undefined in those bodies even though - // the same expression at the declaration site reads `1`. - // - // `CLASS_OBJECT_VALUES` is already the runtime identity used - // by `instance.constructor`. Read that same current - // evaluation first, preserving its own-property and pinned - // static-parent semantics; a miss continues through the - // ordinary ClassRef registry path below. - if !is_prototype_ref { - if let Some(class_object) = - super::super::class_registry::class_object_value_for_cid(class_id) - { - let class_object = JSValue::from_bits(class_object.to_bits()); - if class_object.is_pointer() { - let class_object = class_object.as_pointer::(); - if !class_object.is_null() && class_object as usize != obj as usize { - let value = js_object_get_field_by_name(class_object, key); - if !value.is_undefined() { - return value; - } - } - } - } - } - // Instance (prototype) methods must only resolve when reading - // off the prototype ref (`C.prototype.m`), NOT off the class ref - // itself (`C.m`). In JS a class object does not expose its - // prototype methods as static members: `class C { m(){} }` has - // `C.m === undefined` (the method lives on `C.prototype`). The - // earlier unconditional lookup leaked instance methods onto the - // class ref, so `C.m` returned a (mis-bound) function. This - // broke NestJS interceptor/guard/pipe resolution: its - // `getInterceptorInstance` duck-types `!!metatype.intercept` to - // decide "is this a class or an already-built instance"; a - // truthy `Class.intercept` made it treat the CLASS as the - // instance, so `intercept()` ran with a broken receiver and - // returned `{}`, which rxjs `innerFrom` then rejected. Real - // static methods are resolved below via - // `lookup_static_method_in_chain`. - if is_prototype_ref && class_id != 0 && class_has_own_method(class_id, name) { - let value = class_prototype_method_value_for_name(class_id, name); - return JSValue::from_bits(value.to_bits()); - } - if is_prototype_ref { - // Class accessors are properties of the class prototype - // chain (charter step 3); `this` is the prototype ref. - if let Some((v, _)) = super::super::class_registry::class_chain_getter_value( - class_id, - name, - || class_value, - ) { - return v; - } - return JSValue::undefined(); - } - // Empty-string is a legal static member key (`static get ''()`); - // the `!name.is_empty()` guard below skips it, so resolve a - // static accessor named "" here (Test262 accessor-name-static - // literal-string-empty). - if name.is_empty() { - if let Some(v) = - super::super::class_registry::class_static_accessor_getter_value( - class_id, - name, - class_value, - ) - { - return JSValue::from_bits(v.to_bits()); - } - } - if !name.is_empty() { - if super::super::class_registry::class_is_key_deleted(class_id, name) { - return JSValue::undefined(); - } - let result = crate::object::class_value::class_static_get(class_id, name); - if let Some(v) = result { - return JSValue::from_bits(v.to_bits()); - } - // Static DATA fields are INHERITED by subclasses, exactly like - // static methods: `class D {}; D.kind = "x"; class G extends D {}` - // makes `G.kind === "x"` (the class-object proto chain - // `G.__proto__ === D` carries statics). The own-field read above - // only consulted `class_id`; walk the parent class_id chain here - // so an inherited static field (or runtime `Parent.x = …` - // assignment — both live in CLASS_DYNAMIC_PROPS) resolves. Static - // METHODS are handled by `lookup_static_method_in_chain` below; - // this covers the data-field case that was returning `undefined` - // (Auth.js sets `SignInError.kind = "signIn"` and reads it off a - // `CredentialsSignin` subclass to pick the sign-in vs error page). - // - // #6530: `name` is an OWN property of every constructor — a - // subclass never inherits its parent's `.name` (spec: - // ClassDefinitionEvaluation installs it per class). Skip the - // chain walk so the #2059 own-name synthesis below answers - // with THIS class's registered name instead of an ancestor's. - if !matches!(name, "name" | "length") { - // Walk the class-object proto chain for an inherited static - // DATA field. At EACH level the class's pinned - // per-evaluation parent OBJECT is consulted BEFORE the - // parent's registry props (`CLASS_DYNAMIC_PROPS`). - // - // #6552: a subclass of a class-EXPRESSION value evaluated - // more than once (`function make(a){return class{static - // ast=a}}`, then `class Number$ extends make(x) {}` / - // `class Widget$ extends make(y) {}`) records THIS - // evaluation's parent object as its static prototype - // (`class_prototype_object`, #1788), but the parent's - // `CLASS_DYNAMIC_PROPS` are keyed by the class-expression - // TEMPLATE id — shared, last-wins across every evaluation. - // Reading the registry entry for such a parent collapses - // sibling subclasses to the LAST `make(...)` (effect Schema: - // `Number$.ast`/`Widget$.ast` both read the last parent's - // `ast`). The pinned object carries this evaluation's own - // edge, so it is authoritative; the registry read remains - // the fallback for a plain declaration parent (#6443: - // Auth.js `SignInError.kind`), which has no pinned object. - let mut child = class_id; - let mut depth = 0usize; - while depth < 32 { - // The constructor's own `[[Prototype]]`, set by - // `Object.setPrototypeOf(Ctor, obj)`. Checked first: - // it is the nearest static-side link, and unlike - // `class_prototype_object` it is never on an - // instance's chain. - let static_proto = - super::super::class_registry::class_static_prototype(child); - if !static_proto.is_null() { - // #10911: this walk re-enters with the PARENT as - // the object. It was written when effect's `ast` - // was a static DATA field (see above), where the - // object doesn't matter; effect now makes `ast` a - // static GETTER, and a getter found this way ran - // with `this` === the parent class. Stash the - // class the read started from so the accessor - // binds it (spec OrdinaryGet threads Receiver) -- - // the same device `resolve_proto_chain_field_inner` - // uses for instance getters. - let prev = - crate::object::field_get_set::accessor_receiver_override_begin( - class_value, - ); - let v = js_object_get_field_by_name(static_proto as *const _, key); - crate::object::field_get_set::accessor_receiver_override_end(prev); - if !v.is_undefined() { - return v; - } - } - let proto = super::super::class_registry::class_prototype_object(child); - if !proto.is_null() { - let prev = - crate::object::field_get_set::accessor_receiver_override_begin( - class_value, - ); - let v = js_object_get_field_by_name(proto as *const _, key); - crate::object::field_get_set::accessor_receiver_override_end(prev); - // Return a value present on the pinned object even - // when it is `null` — a static explicitly set to - // `null` on THIS evaluation is authoritative and - // must not fall through to the last-wins registry - // entry (a sibling evaluation's value). Only - // `undefined` means "absent here", which continues - // the walk to the parent's registry props / a higher - // ancestor. - if !v.is_undefined() { - return v; - } - } - let p = match get_parent_class_id(child) { - Some(p) if p != 0 && p != child => p, - _ => break, - }; - // A key deleted on THIS ancestor is not provided by it, - // but a higher ancestor may still define it — `delete - // Mid.foo` must let `Sub.foo` inherit `Base.foo`, not - // resolve to undefined. Skip the registry read for the - // deleted level and keep walking up. - if !super::super::class_registry::class_is_key_deleted(p, name) { - let inherited = - crate::object::class_value::class_static_get(p, name); - if let Some(v) = inherited { - return JSValue::from_bits(v.to_bits()); - } - } - child = p; - depth += 1; - } - } - if super::super::class_registry::lookup_static_method_in_chain(class_id, name) - .is_some() - { - let heap_name = { - let layout = - std::alloc::Layout::from_size_align(name_len.max(1), 1).unwrap(); - let ptr = std::alloc::alloc(layout); - std::ptr::copy_nonoverlapping(name_ptr, ptr, name_len); - ptr - }; - let result = js_class_method_bind(class_value, heap_name, name_len); - return JSValue::from_bits(result.to_bits()); - } - // `class X extends Promise` — a value read of an inherited - // builtin static (`X.resolve`, `X.all`, …) resolves to the - // reified Promise static (so `X.resolve.bind(X)` works). Only - // fires when no user static shadowed it above. - if super::super::promise_parent_in_chain(class_id) - && super::super::promise_static_function_spec(name).is_some() - { - let v = super::super::js_promise_static_function_value(name_ptr, name_len); - if v.to_bits() != crate::value::TAG_UNDEFINED { - return JSValue::from_bits(v.to_bits()); - } - } - if let Some(v) = - super::super::class_registry::class_static_accessor_getter_value( - class_id, - name, - class_value, - ) - { - return JSValue::from_bits(v.to_bits()); - } - // #1788: a subclass of a class-expression value - // (`class Sub extends make("A") {}`) inherits the parent - // class OBJECT's OWN per-evaluation static fields. The - // parent object was recorded as `class_id`'s static - // prototype at `extends` time; walk that chain (also - // covering multi-level `class Leaf extends Mid {}`). - // #6530: except `name` — an own property of every - // constructor, never inherited; without the guard a - // subclass of a per-evaluation class object reported its - // BASE's synthesized `.name` (bundled zod: - // `z.string().constructor.name` gave "ZodType"). - if !matches!(name, "name" | "length") { - if let Some(v) = - super::super::class_registry::resolve_proto_chain_field(class_id, key) - { - if !v.is_undefined() && !v.is_null() { - return v; - } - } - } - // #36 / #321: the subclass extends a FUNCTION value - // (`class Svc extends Context.Tag(id)<...>() {}`). Read the - // named static off the parent closure — its OWN props - // (`Svc.key` → "Svc") plus, via the closure getter, its - // static prototype (`Svc._op` → "Tag" on TagProto). - // #10210: the edge is keyed by the class that directly - // `extends `, which may be an ANCESTOR of this - // class (`class Flags extends ConfigTag {}` where - // `ConfigTag extends Context.Service()(id)`), so walk the - // parent chain like `super()` dispatch does. - if let Some(closure_ptr) = - super::super::class_registry::parent_closure_in_chain(class_id) - { - let v = crate::closure::closure_get_dynamic_prop(closure_ptr, name); - let vb = JSValue::from_bits(v.to_bits()); - if !vb.is_undefined() && !vb.is_null() { - return vb; - } - } - // #2059: the constructor's built-in `name` own property — - // the class name. Checked last so an explicit static - // `name` member (method/field, handled above) still wins. - // This is what `assert.throws` reads via - // `thrown.constructor.name` to label the thrown error. - if name == "name" - && class_id != 0 - && !super::super::class_registry::class_is_key_deleted(class_id, name) - { - if let Some(cname) = - super::super::class_registry::class_name_for_id(class_id) - { - let s = crate::string::js_string_from_bytes( - cname.as_ptr(), - cname.len() as u32, - ); - return JSValue::from_bits(crate::js_nanbox_string(s as i64).to_bits()); - } - } - if name == "length" - && class_id != 0 - && !is_prototype_ref - && !super::super::class_registry::class_is_key_deleted(class_id, name) - { - if let Some(length) = - super::super::class_registry::class_length_for_id(class_id) - { - return JSValue::number(length as f64); - } - } - // A class constructor is also a Function object. Reify - // inherited Function.prototype methods for value reads - // (`const bind = C.bind`) just as the closure path does; - // the captured ClassRef is accepted by native method - // dispatch and by `js_function_bind`. - if !is_prototype_ref { - if let Some(method) = super::reified_function_method_name(name) { - let value = - crate::closure::reify_function_method_value(class_value, method); - return JSValue::from_bits(value.to_bits()); - } - } - // No own static / inherited entry resolved the name. A class - // constructor is a function, so a bare read of `.caller` or - // `.arguments` hits the poison-pill %ThrowTypeError% accessor - // on `Function.prototype` — strict-mode throws (Perry only - // compiles strict code). Placed last so any own static field, - // accessor, or `defineProperty`-installed data prop of that - // name takes precedence. Prototype-refs (`C.prototype`) are - // plain objects and are excluded. - if !is_prototype_ref && matches!(name, "caller" | "arguments") { - crate::fs::validate::throw_type_error_with_code( - "Restricted function property access", - "ERR_INVALID_ARG_TYPE", - ); - } - // #11492: a constructor's chain ends at %Function.prototype%, - // so a user method or expando installed there - // (`Function.prototype.myHelper = fn`) is readable through - // `C.myHelper` exactly as through a closure. - if !is_prototype_ref { - if let Some(v) = - crate::closure::function_prototype_inherited_get(0, name, class_value) - { - return JSValue::from_bits(v.to_bits()); - } - } - } - // The built-in constructor object's `constructor` value is - // inherited from Function.prototype. It is therefore only the - // fallback after own computed fields, static methods, and - // static accessors of the same name have had a chance to win. - if name == "constructor" && class_id != 0 && is_class_id_registered(class_id) { - let constructor = super::super::js_get_global_this_builtin_value( - b"Function".as_ptr(), - b"Function".len(), - ); - return JSValue::from_bits(constructor.to_bits()); - } - } - return JSValue::undefined(); + return class_value_get_field(obj, key, bits, class_id); } } // #1545: Promise `then`/`catch`/`finally` value-reads return a bound diff --git a/crates/perry-runtime/src/object/field_get_set/has_property.rs b/crates/perry-runtime/src/object/field_get_set/has_property.rs index 205b312296..7977869a69 100644 --- a/crates/perry-runtime/src/object/field_get_set/has_property.rs +++ b/crates/perry-runtime/src/object/field_get_set/has_property.rs @@ -1394,6 +1394,18 @@ pub(crate) unsafe fn closure_dynamic_prop_by_key( if key.is_null() { return None; } + // A class function object answers from its class lookup (statics, the + // parent chain, `name`/`length`/`prototype`, Function.prototype) — not the + // plain-function fallbacks, which would walk Function.prototype first. + if let Some(class_id) = crate::object::class_value::class_closure_id(obj) { + let value = super::get_field_by_name::class_value_get_field( + obj as *const crate::object::ObjectHeader, + key, + obj as u64, + class_id, + ); + return Some(f64::from_bits(value.bits())); + } let name = crate::string::header_str_checked(key)?; let val = crate::closure::closure_get_dynamic_prop(obj, name); // Function methods were already resolved, including a getter or own diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 09cdbe6bdf..16ec524e43 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -1365,6 +1365,31 @@ pub unsafe extern "C-unwind" fn js_native_call_method( let object = || object_handle.get_nanbox_f64(); let jsval = || JSValue::from_bits(object().to_bits()); + // A class function object is a class constructor: `C.m()` on one is the + // class arm's (`primitive_methods::dispatch_primitive` — static methods, + // callable static data, Function.prototype methods), reached here before + // the instance / native / own-override arms it would otherwise walk first. + if jsval().is_pointer() + && crate::object::class_value::class_closure_id( + (object().to_bits() & crate::value::POINTER_MASK) as usize, + ) + .is_some() + { + if let Some(r) = primitive_methods::dispatch_primitive( + &root_scope, + &object_handle, + &arg_handles, + object(), + method_name, + method_name_ptr, + method_name_len, + args_ptr, + args_len, + ) { + return r; + } + } + // An explicit `Object.setPrototypeOf(instance, proto)` replaces the // instance's class prototype. Resolve a method value through ordinary // property lookup before any class/native dispatch: that lookup preserves diff --git a/crates/perry-runtime/src/object/this_binding.rs b/crates/perry-runtime/src/object/this_binding.rs index b43dfff694..15f47fdbf6 100644 --- a/crates/perry-runtime/src/object/this_binding.rs +++ b/crates/perry-runtime/src/object/this_binding.rs @@ -173,15 +173,19 @@ pub extern "C" fn js_static_this_resolve(default_this: f64) -> f64 { } /// [`js_static_this_resolve`] for a static method of class `class_id`: the -/// armed override if any, else the class's function object — without the -/// caller materializing the default on every call. +/// armed override if any, else the class's function object, cached in the +/// method's own zero-initialised `slot` (the object is pinned, so the cached +/// bits never go stale) — no per-call class-table lookup. // #1561-style force-keep: only generated IR calls this. #[cfg(feature = "keepalive-anchors")] #[used(compiler)] -static KEEP_JS_STATIC_THIS_RESOLVE_CLASS: extern "C" fn(i32) -> f64 = js_static_this_resolve_class; +static KEEP_JS_STATIC_THIS_RESOLVE_CLASS: unsafe extern "C" fn(i32, *mut f64) -> f64 = + js_static_this_resolve_class; +/// # Safety +/// `slot` is null or the calling static method's own `double` cache global. #[no_mangle] -pub extern "C" fn js_static_this_resolve_class(class_id: i32) -> f64 { +pub unsafe extern "C" fn js_static_this_resolve_class(class_id: i32, slot: *mut f64) -> f64 { let armed = STATIC_THIS_OVERRIDE.with(|c| { let (armed, bits) = c.get(); if armed { @@ -189,10 +193,19 @@ pub extern "C" fn js_static_this_resolve_class(class_id: i32) -> f64 { } armed.then_some(bits) }); - match armed { - Some(bits) => f64::from_bits(bits), - None => super::class_value::class_value(class_id as u32), + if let Some(bits) = armed { + return f64::from_bits(bits); } + if !slot.is_null() && (*slot).to_bits() != 0 { + return *slot; + } + let value = super::class_value::class_value(class_id as u32); + if !slot.is_null() { + // GC_STORE_AUDIT(ROOT): a compiled cache slot holding a PINNED class + // function object (never moves), also rooted by the class-value table. + *slot = value; + } + value } /// Read the current implicit `this` (issue #519). From 31737ffebf831aa01a69911708d4c317b90ed5fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 26 Sep 2026 19:19:21 +0000 Subject: [PATCH 10/29] perf(runtime): class constructors are a shape fact; ordinary receivers pay no class check A class function object now carries its own sticky ShapeId (function_class_shape: dictionary kind, marker proto fact INTRINSIC_SERIAL_CLASS_CONSTRUCTOR_MARKER so it is not the FunctionDictionary id). No own-property transition moves it off that shape. The early class checks a4298db0b put in the generic read and in js_native_call_method's prologue ran for every receiver, and they changed inlining in the dispatcher (map.get +104 instr/op). They are gone. A class receiver is now routed only where a function is already proven and the ordinary test has already failed: - method calls: in the function-shape arm, after the Function.prototype inherits test declines (function_shape_decline, cold) -> class_value_method_call -> class_receiver_arm, which is the class arm split out of dispatch_primitive; - reads: in closure_get_dynamic_prop's non-base branch -> class_static_read. On those hot paths the class test is the code-pointer compare (shape::is_class_code). It is equivalent to the class ShapeId, since both are set at mint and never change, and it needs no thread-local read. The call/apply gates test the INT32 tag first, because a class function object's code is its throwing [[Call]] already. class_closure_id's proof is out of line behind its inline pre-filter. The parent-closure walk no longer answers `name`/`length` (#6530). Per op (instr, perrymaster release, base a26c45070 -> this): map.get 2868 -> 2866, fn.call 2346 -> 2343, fn.prop 686 -> 691, bind 2823 -> 2854 (still open). Class ops: static_get 1314 -> 1494, dyn_static_call 2229 -> 2311, static_call 18 -> 31, dyn_new 6545 -> 6777. --- .../src/closure/dynamic_props.rs | 5 + crates/perry-runtime/src/closure/shape.rs | 59 +++- .../perry-runtime/src/object/class_value.rs | 60 +++- .../perry-runtime/src/object/field_get_set.rs | 1 + .../object/field_get_set/get_field_by_name.rs | 16 +- .../src/object/field_get_set/has_property.rs | 12 - .../src/object/global_this/fetch_globals.rs | 1 - .../src/object/native_call_method.rs | 25 -- .../native_call_method/common_methods.rs | 8 +- .../native_call_method/function_shape.rs | 24 +- .../native_call_method/primitive_methods.rs | 278 ++++++++++++------ 11 files changed, 337 insertions(+), 152 deletions(-) diff --git a/crates/perry-runtime/src/closure/dynamic_props.rs b/crates/perry-runtime/src/closure/dynamic_props.rs index af3f8d8100..234f33f9d6 100644 --- a/crates/perry-runtime/src/closure/dynamic_props.rs +++ b/crates/perry-runtime/src/closure/dynamic_props.rs @@ -350,6 +350,11 @@ pub fn closure_get_dynamic_prop(ptr: usize, prop: &str) -> f64 { let on_base = unsafe { super::shape::closure_on_base_shape(ptr as *const ClosureHeader) }; if on_base { // fall through to the data lookups below + } else if super::shape::is_class_code(unsafe { (*(ptr as *const ClosureHeader)).func_ptr }) { + // A class constructor: its class lookup (statics, the parent chain, + // `name`/`length`/`prototype`, Function.prototype) — never the plain + // function fallbacks below. + return crate::object::class_value::class_static_read(ptr, prop); } else if let Some(acc) = crate::object::get_accessor_descriptor(ptr, prop) { if acc.get == 0 { return f64::from_bits(crate::value::TAG_UNDEFINED); diff --git a/crates/perry-runtime/src/closure/shape.rs b/crates/perry-runtime/src/closure/shape.rs index 6413f116b9..a1e19baf6e 100644 --- a/crates/perry-runtime/src/closure/shape.rs +++ b/crates/perry-runtime/src/closure/shape.rs @@ -32,6 +32,12 @@ pub(crate) const INTRINSIC_SERIAL_FUNCTION: u64 = 1; pub(crate) const INTRINSIC_SERIAL_ASYNC_FUNCTION: u64 = 2; pub(crate) const INTRINSIC_SERIAL_GENERATOR_FUNCTION: u64 = 3; pub(crate) const INTRINSIC_SERIAL_ASYNC_GENERATOR_FUNCTION: u64 = 4; +/// Not a prototype: the marker `proto_id` of the class-constructor ShapeId +/// ([`function_class_shape`]). Shapes are canonical per facts, so without a +/// fact of its own the class shape would BE the FunctionDictionary id. No +/// object is ever assigned this serial; a class constructor's real +/// [[Prototype]] lives on the object (dictionary kind: "ask the object"). +pub(crate) const INTRINSIC_SERIAL_CLASS_CONSTRUCTOR_MARKER: u64 = 5; /// Which intrinsic prototype a function BODY's closures inherit from. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -96,6 +102,9 @@ crate::perry_thread_local! { /// This agent's base Function ShapeIds, indexed by `FunctionProtoKind`, /// then the FunctionDictionary id (0 = not minted yet). static BASE_SHAPES: std::cell::Cell<[u32; 5]> = const { std::cell::Cell::new([0; 5]) }; + /// This agent's class-constructor ShapeId (0 = not minted yet). Its own + /// cell: the base-shape array is copied on every closure birth. + static CLASS_SHAPE: std::cell::Cell = const { std::cell::Cell::new(0) }; /// One-entry body cache: the last `func_ptr` born and its base shape. static LAST_BODY: std::cell::Cell<(usize, u32)> = const { std::cell::Cell::new((0, 0)) }; } @@ -178,6 +187,39 @@ pub(crate) fn function_dictionary_shape() -> u32 { ) } +/// The ShapeId of every class function object (`object::class_value`): its +/// kind is a shape fact. Dictionary-kind ("ask the object": statics, the +/// recorded [[Prototype]], accessors live on the object) and STICKY — no +/// own-property transition moves a class function object off it, so a site +/// that compares ShapeIds tells a class constructor from any other function +/// with that one compare. +#[inline] +pub(crate) fn function_class_shape() -> u32 { + let mut id = CLASS_SHAPE.with(std::cell::Cell::get); + if id == 0 { + id = mint( + ShapeObjectKind::FunctionDictionary, + INTRINSIC_SERIAL_CLASS_CONSTRUCTOR_MARKER, + ); + CLASS_SHAPE.with(|c| c.set(id)); + } + debug_assert_ne!( + id, + function_dictionary_shape(), + "the class shape is its own id" + ); + id +} + +/// Is `func_ptr` the class [[Call]] code — i.e. is a closure carrying it a +/// class function object? Equivalent to its ShapeId being +/// [`function_class_shape`] (both are set at mint and never change); used on +/// hot paths where the shape id would need a thread-local read. +#[inline(always)] +pub(crate) fn is_class_code(func_ptr: *const u8) -> bool { + func_ptr == crate::object::class_value::js_class_constructor_called as *const u8 +} + /// The ShapeId a fresh closure of `func_ptr` is born with. #[inline] pub(crate) fn birth_shape_for_body(func_ptr: *const u8) -> u32 { @@ -214,10 +256,14 @@ pub(crate) unsafe fn closure_on_base_shape(closure: *const ClosureHeader) -> boo let id = (*closure).shape_id; debug_assert!( id == function_dictionary_shape() + || id == function_class_shape() || shapes::shape_object_kind_by_id(id) == Some(ShapeObjectKind::Function), - "a closure carries a Function or the FunctionDictionary shape: {id:#x}" + "a closure carries a Function, FunctionDictionary or class shape: {id:#x}" ); - id != function_dictionary_shape() + // The class shape is sticky and implies the class [[Call]] code pointer, + // so the code-pointer compare (a link-time constant, no thread-local + // read) excludes it for free on this hot path. + id != function_dictionary_shape() && !is_class_code((*closure).func_ptr) } /// Record that `closure` now answers something its base shape does not: @@ -230,7 +276,9 @@ pub(crate) unsafe fn closure_on_base_shape(closure: *const ClosureHeader) -> boo #[inline] pub(crate) unsafe fn closure_become_dictionary(closure: *mut ClosureHeader) { let dict = function_dictionary_shape(); - if (*closure).shape_id != dict { + let id = (*closure).shape_id; + // A class function object keeps its (sticky, dictionary-kind) class shape. + if id != dict && !is_class_code((*closure).func_ptr) { // GC_STORE_AUDIT(POINTER_FREE): a ShapeId, never a heap reference. (*closure).shape_id = dict; } @@ -252,7 +300,7 @@ pub(crate) fn refresh_closure_shape(ptr: usize) { unsafe { let closure = ptr as *mut ClosureHeader; let dict = function_dictionary_shape(); - if (*closure).shape_id == dict { + if (*closure).shape_id == dict || is_class_code((*closure).func_ptr) { return; } if super::props::has_state(ptr) { @@ -310,7 +358,8 @@ pub(crate) fn function_shape_inherits_from_function_prototype(id: u32, key: &[u8 if id == function_dictionary_shape() { return false; } - // A keyed shape: its verdict for the three Function.prototype intrinsics + // A keyed shape (the class shape is dictionary-kind: the verdict below + // answers false for it without a compare of its own): its verdict for the three Function.prototype intrinsics // is a fact of the (immutable) ShapeId, cached per agent. let bit = match key { b"bind" => VERDICT_BIND, diff --git a/crates/perry-runtime/src/object/class_value.rs b/crates/perry-runtime/src/object/class_value.rs index 61fe0267cc..d0010c1be6 100644 --- a/crates/perry-runtime/src/object/class_value.rs +++ b/crates/perry-runtime/src/object/class_value.rs @@ -63,6 +63,14 @@ pub fn class_closure_id(ptr: usize) -> Option { if !crate::object::shapes::is_exotic_shape_id(shape) { return None; } + class_closure_id_exotic(ptr) +} + +/// [`class_closure_id`] past its inline pre-filter (a plausible, aligned heap +/// address whose ShapeId word is in the exotic band): out of line, so the +/// many gates that inline the pre-filter stay small. +#[inline(never)] +fn class_closure_id_exotic(ptr: usize) -> Option { // SAFETY: an exotic-band ShapeId word means a closure-or-exotic header, // at least 16 bytes; +8 is the code pointer of a closure. let code = unsafe { *((ptr as *const u8).add(8) as *const *const u8) }; @@ -225,7 +233,7 @@ fn class_value_mint(class_id: u32) -> *mut ClosureHeader { // GC_STORE_AUDIT(INIT): fresh class function object; the one capture // is an INT32 class id and the props edge is null — pointer-free. (*ptr).capture_count = 1; - (*ptr).shape_id = crate::closure::shape::function_dictionary_shape(); + (*ptr).shape_id = crate::closure::shape::function_class_shape(); (*ptr).func_ptr = js_class_constructor_called as *const u8; (*ptr).props = std::ptr::null_mut(); std::ptr::write( @@ -288,6 +296,27 @@ pub(crate) fn scan_class_value_roots_mut(visitor: &mut crate::gc::RuntimeRootVis } } +/// `C[prop]` for a class function object at `ptr` (routed by +/// `closure_get_dynamic_prop` on the class ShapeId): the class lookup. +#[cold] +#[inline(never)] +pub(crate) fn class_static_read(ptr: usize, prop: &str) -> f64 { + // SAFETY: the caller proved a live class closure (its ShapeId). + let Some(class_id) = (unsafe { class_closure_id_unchecked(ptr as *const ClosureHeader) }) + else { + return f64::from_bits(crate::value::TAG_UNDEFINED); + }; + // The class object is pinned: `ptr` survives the key allocation. + let key = crate::string::js_string_from_bytes(prop.as_ptr(), prop.len() as u32); + let value = crate::object::field_get_set::class_value_get_field( + ptr as *const crate::object::ObjectHeader, + key, + ptr as u64, + class_id, + ); + f64::from_bits(value.bits()) +} + // --------------------------------------------------------------------------- // Statics: the class function object's OWN properties. // --------------------------------------------------------------------------- @@ -419,6 +448,35 @@ mod tests { assert!(seen, "the class-value table must be a GC root"); } + /// The kind is a shape fact: class function objects carry their own + /// ShapeId, distinct from FunctionDictionary (shapes are canonical per + /// facts — without its marker fact the class shape WOULD be the dictionary + /// id and every dictionary function would route as a class), and it is + /// sticky across own-property installs. + #[test] + fn class_function_objects_have_their_own_sticky_shape() { + let cid = 0x6C01; + register(cid); + let class_shape = crate::closure::shape::function_class_shape(); + assert_ne!( + class_shape, + crate::closure::shape::function_dictionary_shape() + ); + let ptr = class_value_ptr(cid); + assert_eq!(unsafe { (*ptr).shape_id }, class_shape); + class_static_set(cid, "s", 1.0); + crate::closure::shape::note_function_own_state_changed(ptr as usize); + assert_eq!(unsafe { (*ptr).shape_id }, class_shape, "sticky"); + extern "C" fn body() {} + let f = crate::closure::js_closure_alloc(body as *const u8, 0); + crate::closure::shape::note_function_own_state_changed(f as usize); + assert_ne!( + unsafe { (*f).shape_id }, + class_shape, + "a dictionary function is not a class" + ); + } + /// Only the function object's own code pointer names a class. #[test] fn ordinary_closures_and_numbers_are_not_class_values() { diff --git a/crates/perry-runtime/src/object/field_get_set.rs b/crates/perry-runtime/src/object/field_get_set.rs index 262e196a2d..d2822a736a 100644 --- a/crates/perry-runtime/src/object/field_get_set.rs +++ b/crates/perry-runtime/src/object/field_get_set.rs @@ -291,6 +291,7 @@ pub use field_ops::{ js_value_to_object, }; pub use for_in_stable::js_for_in_keys_stable_value; +pub(crate) use get_field_by_name::class_value_get_field; pub use get_field_by_name::js_object_get_field_by_name; pub(crate) use get_field_by_name_async::async_resource_property; pub(crate) use get_field_by_name_tail::get_field_by_name_object_tail; diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index 56f1de6e01..87f9ee9950 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -390,8 +390,11 @@ pub(crate) fn class_value_get_field( // class (`class Flags extends ConfigTag {}` where // `ConfigTag extends Context.Service()(id)`), so walk the // parent chain like `super()` dispatch does. - if let Some(closure_ptr) = - super::super::class_registry::parent_closure_in_chain(class_id) + // `name`/`length` are own properties of every constructor (#6530), + // never inherited from a function-valued parent. + if let Some(closure_ptr) = (!matches!(name, "name" | "length")) + .then(|| super::super::class_registry::parent_closure_in_chain(class_id)) + .flatten() { let v = crate::closure::closure_get_dynamic_prop(closure_ptr, name); let vb = JSValue::from_bits(v.to_bits()); @@ -576,15 +579,6 @@ pub(crate) fn get_field_by_name_past_inherited_cache( if key.is_null() { return JSValue::undefined(); } - // A class function object (not the legacy immediate, which keeps its - // later arm) is a class constructor: its statics are the class lookup's. - // One ShapeId-word pre-filter for every other receiver. - { - let raw = (obj as u64 & crate::value::POINTER_MASK) as usize; - if let Some(class_id) = crate::object::class_value::class_closure_id(raw) { - return class_value_get_field(obj, key, obj as u64, class_id); - } - } // `process.env` is a live OS-backed exotic object. Direct reads are // codegen-specialized, but an alias (`const env = process.env; env.X`) // reaches this generic path. On Windows the OS lookup also supplies the diff --git a/crates/perry-runtime/src/object/field_get_set/has_property.rs b/crates/perry-runtime/src/object/field_get_set/has_property.rs index 7977869a69..205b312296 100644 --- a/crates/perry-runtime/src/object/field_get_set/has_property.rs +++ b/crates/perry-runtime/src/object/field_get_set/has_property.rs @@ -1394,18 +1394,6 @@ pub(crate) unsafe fn closure_dynamic_prop_by_key( if key.is_null() { return None; } - // A class function object answers from its class lookup (statics, the - // parent chain, `name`/`length`/`prototype`, Function.prototype) — not the - // plain-function fallbacks, which would walk Function.prototype first. - if let Some(class_id) = crate::object::class_value::class_closure_id(obj) { - let value = super::get_field_by_name::class_value_get_field( - obj as *const crate::object::ObjectHeader, - key, - obj as u64, - class_id, - ); - return Some(f64::from_bits(value.bits())); - } let name = crate::string::header_str_checked(key)?; let val = crate::closure::closure_get_dynamic_prop(obj, name); // Function methods were already resolved, including a getter or own diff --git a/crates/perry-runtime/src/object/global_this/fetch_globals.rs b/crates/perry-runtime/src/object/global_this/fetch_globals.rs index c3693ebe4c..8cd97c49d1 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -1027,7 +1027,6 @@ pub unsafe extern "C" fn js_fetch_or_value_super( const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; const TAG_MASK: u64 = 0xFFFF_0000_0000_0000; const PTR_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; - const INT32_TAG: u64 = 0x7FFE_0000_0000_0000; // A dynamic parent that resolved to a ClassRef (INT32-tagged) is a // real registered Perry class — `class X extends _mod.default` // where the default export is a user class (Next.js diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 16ec524e43..09cdbe6bdf 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -1365,31 +1365,6 @@ pub unsafe extern "C-unwind" fn js_native_call_method( let object = || object_handle.get_nanbox_f64(); let jsval = || JSValue::from_bits(object().to_bits()); - // A class function object is a class constructor: `C.m()` on one is the - // class arm's (`primitive_methods::dispatch_primitive` — static methods, - // callable static data, Function.prototype methods), reached here before - // the instance / native / own-override arms it would otherwise walk first. - if jsval().is_pointer() - && crate::object::class_value::class_closure_id( - (object().to_bits() & crate::value::POINTER_MASK) as usize, - ) - .is_some() - { - if let Some(r) = primitive_methods::dispatch_primitive( - &root_scope, - &object_handle, - &arg_handles, - object(), - method_name, - method_name_ptr, - method_name_len, - args_ptr, - args_len, - ) { - return r; - } - } - // An explicit `Object.setPrototypeOf(instance, proto)` replaces the // instance's class prototype. Resolve a method value through ordinary // property lookup before any class/native dispatch: that lookup preserves diff --git a/crates/perry-runtime/src/object/native_call_method/common_methods.rs b/crates/perry-runtime/src/object/native_call_method/common_methods.rs index f5c532946a..302adcdafe 100644 --- a/crates/perry-runtime/src/object/native_call_method/common_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/common_methods.rs @@ -716,8 +716,10 @@ pub(crate) unsafe fn dispatch_function_proto_method( "call" => { // Class constructors have no [[Call]] slot. `C.call(...)` must // reject instead of treating the INT32-tagged ClassRef payload as - // a closure pointer in the generic Function.prototype path. - if super::class_ref_id(object).is_some() { + // a closure pointer in the generic Function.prototype path. (A + // class FUNCTION OBJECT needs no gate: its code is the throwing + // [[Call]] `js_class_constructor_called`.) + if (object.to_bits() >> 48) == 0x7FFE && super::class_ref_id(object).is_some() { throw_fn_proto_not_callable("call"); } // Proxy receiver (#3656): `p.call(thisArg, ...args)` routes through @@ -799,7 +801,7 @@ pub(crate) unsafe fn dispatch_function_proto_method( } } "apply" => { - if super::class_ref_id(object).is_some() { + if (object.to_bits() >> 48) == 0x7FFE && super::class_ref_id(object).is_some() { throw_fn_proto_not_callable("apply"); } // Proxy receiver (#3656): `p.apply(thisArg, argsArray)` routes diff --git a/crates/perry-runtime/src/object/native_call_method/function_shape.rs b/crates/perry-runtime/src/object/native_call_method/function_shape.rs index 6be6c0851f..10ac520e5f 100644 --- a/crates/perry-runtime/src/object/native_call_method/function_shape.rs +++ b/crates/perry-runtime/src/object/native_call_method/function_shape.rs @@ -60,7 +60,11 @@ pub(crate) unsafe fn try_function_shape_method_call( return dictionary_function_proto_method_call(object, addr, name, args_ptr, args_len); } if !crate::closure::shape::function_shape_inherits_from_function_prototype(word, name) { - return None; + // A class constructor (`C.m()` on a class value): the class arm — + // statics, callable static data, Function.prototype methods. Reached + // only once the ordinary function test failed, so no other receiver + // pays for it. + return function_shape_decline(object, addr, name, args_ptr, args_len); } // (2) The prototype the shape names, and its own data slot for the key. let proto = @@ -89,6 +93,24 @@ pub(crate) unsafe fn try_function_shape_method_call( /// from the receiver's ACTUAL prototype (`reify_function_method_value`, which /// reads `getPrototypeOf(fn)`). The intrinsic runs the tower's semantics; any /// other callable (`p.call`) is invoked with the function as `this`. +/// The shape arm declined an inherited Function.prototype method: a class +/// constructor goes to the class arm; anything else back to the tower. Out of +/// line so the inlined arm stays small. +#[cold] +#[inline(never)] +unsafe fn function_shape_decline( + object: f64, + addr: usize, + name: &[u8], + args_ptr: *const f64, + args_len: usize, +) -> Option { + if crate::closure::shape::is_class_code((*(addr as *const ClosureHeader)).func_ptr) { + return super::primitive_methods::class_value_method_call(object, name, args_ptr, args_len); + } + None +} + unsafe fn dictionary_function_proto_method_call( object: f64, addr: usize, diff --git a/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs b/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs index 88a08d5448..878ba10073 100644 --- a/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs @@ -1,6 +1,174 @@ use super::typed_array::*; use super::*; +/// `C.m(args)` on a class function object (routed by the function-shape arm, +/// which proved the class ShapeId): the class arm of [`dispatch_primitive`] +/// with its own rooting. `None` falls back to the generic tower. +/// +/// # Safety +/// `args_ptr` is valid for `args_len` reads (or null with `args_len == 0`). +#[cold] +#[inline(never)] +pub(crate) unsafe fn class_value_method_call( + object: f64, + name: &[u8], + args_ptr: *const f64, + args_len: usize, +) -> Option { + let method_name = std::str::from_utf8(name).ok()?; + let root_scope = crate::gc::RuntimeHandleScope::new(); + let object_handle = root_scope.root_nanbox_f64(object); + let original_args: Vec = if args_len > 0 && !args_ptr.is_null() { + std::slice::from_raw_parts(args_ptr, args_len).to_vec() + } else { + Vec::new() + }; + let arg_handles = root_scope.root_nanbox_f64_slice(&original_args); + let class_id = crate::object::class_value::class_value_id(object)?; + if let Some(r) = class_receiver_arm( + class_id, + &root_scope, + &object_handle, + &arg_handles, + object_handle.get_nanbox_f64(), + method_name, + name.as_ptr() as *const i8, + name.len(), + args_ptr, + args_len, + ) { + return Some(r); + } + dispatch_primitive( + &root_scope, + &object_handle, + &arg_handles, + object_handle.get_nanbox_f64(), + method_name, + name.as_ptr() as *const i8, + name.len(), + args_ptr, + args_len, + ) +} + +/// The class-constructor arm of the method tower: `C.m(args)` on a class +/// value (its function object, routed here by the function-shape arm, or the +/// legacy INT32 / `C.prototype` immediate from `dispatch_primitive`). +#[allow(clippy::too_many_arguments)] +#[inline(never)] +unsafe fn class_receiver_arm( + class_id: u32, + root_scope: &crate::gc::RuntimeHandleScope, + object_handle: &crate::gc::RuntimeHandle, + arg_handles: &[crate::gc::RuntimeHandle], + object: f64, + method_name: &str, + method_name_ptr: *const i8, + method_name_len: usize, + args_ptr: *const f64, + args_len: usize, +) -> Option { + let refreshed_args = || crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(arg_handles); + let _ = (root_scope, &refreshed_args); + if crate::object::class_prototype_ref_id(object).is_some() { + if let Some((func_ptr, param_count, has_synthetic_arguments, has_rest)) = + crate::object::class_registry::lookup_class_method_in_chain(class_id, method_name) + { + return Some(crate::object::class_registry::call_vtable_method( + func_ptr, + object.to_bits() as i64, + args_ptr, + args_len, + param_count, + has_synthetic_arguments, + has_rest, + )); + } + } else if class_id != 0 + && crate::object::class_registry::lookup_static_method_in_chain(class_id, method_name) + .is_some() + { + let args = refreshed_args(); + return Some(crate::object::class_registry::js_class_static_method_call( + object_handle.get_nanbox_f64(), + method_name_ptr as *const u8, + method_name_len, + args.as_ptr(), + args.len(), + )); + } else if class_id != 0 + && matches!( + method_name, + "bind" | "call" | "apply" | "isPrototypeOf" | "toString" + ) + && crate::object::class_registry::class_own_static_field_value(class_id, method_name) + .is_none() + { + // These are inherited Function/Object prototype operations, not + // static data members. Let `dispatch_common` handle them. Looking + // them up as a class property here reifies a bound method whose + // dispatch re-enters this same arm indefinitely (`C.call(...)` + // exhausted the native stack instead of throwing TypeError). + return match method_name { + "bind" => Some(crate::closure::js_function_bind(object, args_ptr, args_len)), + "call" | "apply" => super::proto_dispatch::throw_fn_proto_not_callable(method_name), + _ => None, + }; + } else if class_id != 0 && !method_name_ptr.is_null() && method_name_len > 0 { + // #5437: `C.viaFn()` where `viaFn` is a static DATA property holding a + // callable (`C.viaFn = fn` / `static viaFn = fn`), NOT a registered + // static method. A class reference VALUE is an INT32-tagged class id, + // not a heap object, so the generic object field-scan below can't deref + // it; and these statics live in CLASS_DYNAMIC_PROPS, not the static- + // method vtable, so the arm above misses them. The bug surfaced as a + // method call on a class returned from / aliased through a function + // (`const D = C; D.viaFn()`), where the static analyzer couldn't prove + // the receiver is a class object and lowered it to this dynamic path. + // Resolve the property exactly as the read-then-call path does + // (`js_object_get_field_by_name` walks the class-ref static chain), + // then invoke the callable with `this` bound to the class ref — + // mirroring `const f = C.viaFn; f()`, which already worked. + let key_ptr = crate::string::js_string_from_bytes( + method_name_ptr as *const u8, + method_name_len as u32, + ); + let prop = js_object_get_field_by_name(object.to_bits() as *const ObjectHeader, key_ptr); + let prop_bits = prop.bits(); + let raw = (prop_bits & crate::value::POINTER_MASK) as usize; + if (prop_bits & crate::value::TAG_MASK) == crate::value::POINTER_TAG + && crate::closure::is_closure_ptr(raw) + { + // Rebind the closure's reserved `this` slot to the class ref, as + // the prototype/field method-dispatch arms above do. A static + // data property holding an object-literal method (`captures_this`) + // bakes `this` into a capture slot that `IMPLICIT_THIS` alone + // can't override; `clone_closure_rebind_this` is a no-op for + // closures that don't capture `this`, so plain functions and + // arrows are unaffected. + let bound = crate::closure::clone_closure_rebind_this( + prop_bits, + object_handle.get_nanbox_f64(), + ); + let prop_handle = root_scope.root_nanbox_f64(f64::from_bits(bound)); + let args = refreshed_args(); + // #8495: root the displaced receiver across the call below. + let prev_this_scope = crate::gc::RuntimeHandleScope::new(); + let prev_this_h = prev_this_scope.root_nanbox_u64( + IMPLICIT_THIS.with(|c| c.replace(object_handle.get_nanbox_f64().to_bits())), + ); + let result = crate::closure::js_native_call_value( + prop_handle.get_nanbox_f64(), + args.as_ptr(), + args.len(), + ); + IMPLICIT_THIS.with(|c| c.set(prev_this_h.get_nanbox_u64())); + return Some(result); + } + } + None +} + pub(super) unsafe fn dispatch_primitive( root_scope: &crate::gc::RuntimeHandleScope, object_handle: &crate::gc::RuntimeHandle, @@ -34,101 +202,25 @@ pub(super) unsafe fn dispatch_primitive( )); } - if let Some(class_id) = crate::object::class_value::legacy_class_value_word(object.to_bits()) { - if crate::object::class_prototype_ref_id(object).is_some() { - if let Some((func_ptr, param_count, has_synthetic_arguments, has_rest)) = - crate::object::class_registry::lookup_class_method_in_chain(class_id, method_name) - { - return Some(crate::object::class_registry::call_vtable_method( - func_ptr, - object.to_bits() as i64, - args_ptr, - args_len, - param_count, - has_synthetic_arguments, - has_rest, - )); - } - } else if class_id != 0 - && crate::object::class_registry::lookup_static_method_in_chain(class_id, method_name) - .is_some() + // A legacy class value (the INT32 immediate or `C.prototype`); class + // function objects arrive through `class_value_method_call`. + if (object.to_bits() >> 48) == 0x7FFE { + if let Some(class_id) = + crate::object::class_value::legacy_class_value_word(object.to_bits()) { - let args = refreshed_args(); - return Some(crate::object::class_registry::js_class_static_method_call( - object_handle.get_nanbox_f64(), - method_name_ptr as *const u8, - method_name_len, - args.as_ptr(), - args.len(), - )); - } else if class_id != 0 - && matches!( + if let Some(r) = class_receiver_arm( + class_id, + root_scope, + object_handle, + arg_handles, + object, method_name, - "bind" | "call" | "apply" | "isPrototypeOf" | "toString" - ) - && crate::object::class_registry::class_own_static_field_value(class_id, method_name) - .is_none() - { - // These are inherited Function/Object prototype operations, not - // static data members. Let `dispatch_common` handle them. Looking - // them up as a class property here reifies a bound method whose - // dispatch re-enters this same arm indefinitely (`C.call(...)` - // exhausted the native stack instead of throwing TypeError). - return match method_name { - "bind" => Some(crate::closure::js_function_bind(object, args_ptr, args_len)), - "call" | "apply" => super::proto_dispatch::throw_fn_proto_not_callable(method_name), - _ => None, - }; - } else if class_id != 0 && !method_name_ptr.is_null() && method_name_len > 0 { - // #5437: `C.viaFn()` where `viaFn` is a static DATA property holding a - // callable (`C.viaFn = fn` / `static viaFn = fn`), NOT a registered - // static method. A class reference VALUE is an INT32-tagged class id, - // not a heap object, so the generic object field-scan below can't deref - // it; and these statics live in CLASS_DYNAMIC_PROPS, not the static- - // method vtable, so the arm above misses them. The bug surfaced as a - // method call on a class returned from / aliased through a function - // (`const D = C; D.viaFn()`), where the static analyzer couldn't prove - // the receiver is a class object and lowered it to this dynamic path. - // Resolve the property exactly as the read-then-call path does - // (`js_object_get_field_by_name` walks the class-ref static chain), - // then invoke the callable with `this` bound to the class ref — - // mirroring `const f = C.viaFn; f()`, which already worked. - let key_ptr = crate::string::js_string_from_bytes( - method_name_ptr as *const u8, - method_name_len as u32, - ); - let prop = - js_object_get_field_by_name(object.to_bits() as *const ObjectHeader, key_ptr); - let prop_bits = prop.bits(); - let raw = (prop_bits & crate::value::POINTER_MASK) as usize; - if (prop_bits & crate::value::TAG_MASK) == crate::value::POINTER_TAG - && crate::closure::is_closure_ptr(raw) - { - // Rebind the closure's reserved `this` slot to the class ref, as - // the prototype/field method-dispatch arms above do. A static - // data property holding an object-literal method (`captures_this`) - // bakes `this` into a capture slot that `IMPLICIT_THIS` alone - // can't override; `clone_closure_rebind_this` is a no-op for - // closures that don't capture `this`, so plain functions and - // arrows are unaffected. - let bound = crate::closure::clone_closure_rebind_this( - prop_bits, - object_handle.get_nanbox_f64(), - ); - let prop_handle = root_scope.root_nanbox_f64(f64::from_bits(bound)); - let args = refreshed_args(); - // #8495: root the displaced receiver across the call below. - let prev_this_scope = crate::gc::RuntimeHandleScope::new(); - let prev_this_h = prev_this_scope.root_nanbox_u64( - IMPLICIT_THIS.with(|c| c.replace(object_handle.get_nanbox_f64().to_bits())), - ); - let result = crate::closure::js_native_call_value( - prop_handle.get_nanbox_f64(), - args.as_ptr(), - args.len(), - ); - IMPLICIT_THIS.with(|c| c.set(prev_this_h.get_nanbox_u64())); - return Some(result); + method_name_ptr, + method_name_len, + args_ptr, + args_len, + ) { + return Some(r); } } } From 5317821d5d766900ab104e4fd9ef2f01dc8f94b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 26 Sep 2026 20:02:16 +0000 Subject: [PATCH 11/29] perf(runtime): keep value_is_callable inlined (bind path) It went out of line once its class probe moved; bind reads it twice per call. --- crates/perry-runtime/src/object/instanceof.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index 4c573cde06..4c4c13bb45 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -33,6 +33,7 @@ pub use static_dispatch::js_instanceof; /// representation: heap closures (declarations / expressions / arrows / /// methods / bound functions / built-in constructors, all carrying /// `CLOSURE_MAGIC`) and small native function handles. +#[inline] pub(crate) fn value_is_callable(value: f64) -> bool { if crate::value::is_js_handle(value) && crate::value::js_handle_is_function(value) { return true; From c77b882747b0f1990dbe6050dd5400c31a240484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 26 Sep 2026 21:56:43 +0000 Subject: [PATCH 12/29] perf(runtime): class reads use the caller's key header; ordinary functions rejected inline A read of a class value that reached closure_get_dynamic_prop's class branch (class_static_read) allocated a key string on every read: zod's libc memmove/memcmp share rose by 11.8M instructions. The object tail and the IC-miss closure arm now pass their key header through (closure_get_dynamic_prop_keyed / class_closure_read_by_key), so a class read builds no string. class_closure_id rejects an ordinary function inline: the code-pointer compare comes before the out-of-line proof. --- .../src/closure/dynamic_props.rs | 12 ++++++++++- crates/perry-runtime/src/closure/mod.rs | 7 ++++--- .../perry-runtime/src/object/class_value.rs | 19 +++++++++++------ .../field_get_set/get_field_by_name_tail.rs | 10 +++++++++ .../src/object/field_get_set/has_property.rs | 21 ++++++++++++++++++- 5 files changed, 58 insertions(+), 11 deletions(-) diff --git a/crates/perry-runtime/src/closure/dynamic_props.rs b/crates/perry-runtime/src/closure/dynamic_props.rs index 234f33f9d6..80c36cd6a1 100644 --- a/crates/perry-runtime/src/closure/dynamic_props.rs +++ b/crates/perry-runtime/src/closure/dynamic_props.rs @@ -326,6 +326,16 @@ pub extern "C" fn js_value_is_closure(value_bits: i64) -> i32 { /// Get a dynamic property stored on a closure. /// Returns TAG_UNDEFINED if not found. pub fn closure_get_dynamic_prop(ptr: usize, prop: &str) -> f64 { + closure_get_dynamic_prop_keyed(ptr, prop, std::ptr::null()) +} + +/// [`closure_get_dynamic_prop`] with the caller's key header, when it has one +/// (`key` may be null): a class constructor's read then builds no key string. +pub(crate) fn closure_get_dynamic_prop_keyed( + ptr: usize, + prop: &str, + key: *const crate::StringHeader, +) -> f64 { if !is_closure_ptr(ptr) { return f64::from_bits(crate::value::TAG_UNDEFINED); } @@ -354,7 +364,7 @@ pub fn closure_get_dynamic_prop(ptr: usize, prop: &str) -> f64 { // A class constructor: its class lookup (statics, the parent chain, // `name`/`length`/`prototype`, Function.prototype) — never the plain // function fallbacks below. - return crate::object::class_value::class_static_read(ptr, prop); + return crate::object::class_value::class_static_read(ptr, prop, key); } else if let Some(acc) = crate::object::get_accessor_descriptor(ptr, prop) { if acc.get == 0 { return f64::from_bits(crate::value::TAG_UNDEFINED); diff --git a/crates/perry-runtime/src/closure/mod.rs b/crates/perry-runtime/src/closure/mod.rs index 7dac269eb0..ea3980712a 100644 --- a/crates/perry-runtime/src/closure/mod.rs +++ b/crates/perry-runtime/src/closure/mod.rs @@ -93,9 +93,10 @@ pub(crate) use dynamic_props::test_clear_closure_side_tables; pub(crate) use dynamic_props::{ clear_closure_side_tables_for_dead_ptr, clone_closure_rebind_this, closure_dynamic_props_owner_moved, closure_dynamic_side_tables_nonempty, - closure_set_via_function_prototype_descriptor, function_prototype_fallback_target, - function_prototype_inherited_get, prune_dead_closure_side_table_owners, - prune_dead_closure_side_table_owners_young, release_closure_side_table_owners_in_ranges, + closure_get_dynamic_prop_keyed, closure_set_via_function_prototype_descriptor, + function_prototype_fallback_target, function_prototype_inherited_get, + prune_dead_closure_side_table_owners, prune_dead_closure_side_table_owners_young, + release_closure_side_table_owners_in_ranges, }; pub use dynamic_props::{ closure_delete_own_dynamic_prop, closure_dynamic_props_snapshot, closure_get_dynamic_prop, diff --git a/crates/perry-runtime/src/object/class_value.rs b/crates/perry-runtime/src/object/class_value.rs index d0010c1be6..946edf2cea 100644 --- a/crates/perry-runtime/src/object/class_value.rs +++ b/crates/perry-runtime/src/object/class_value.rs @@ -63,6 +63,12 @@ pub fn class_closure_id(ptr: usize) -> Option { if !crate::object::shapes::is_exotic_shape_id(shape) { return None; } + // SAFETY: an exotic-band ShapeId word means a closure-or-exotic header, + // at least 16 bytes; +8 is the code pointer of a closure. + let code = unsafe { *((ptr as *const u8).add(8) as *const *const u8) }; + if code != js_class_constructor_called as *const u8 { + return None; + } class_closure_id_exotic(ptr) } @@ -71,10 +77,7 @@ pub fn class_closure_id(ptr: usize) -> Option { /// many gates that inline the pre-filter stay small. #[inline(never)] fn class_closure_id_exotic(ptr: usize) -> Option { - // SAFETY: an exotic-band ShapeId word means a closure-or-exotic header, - // at least 16 bytes; +8 is the code pointer of a closure. - let code = unsafe { *((ptr as *const u8).add(8) as *const *const u8) }; - if code != js_class_constructor_called as *const u8 || !crate::closure::is_closure_ptr(ptr) { + if !crate::closure::is_closure_ptr(ptr) { return None; } // SAFETY: `is_closure_ptr` proved a live, non-forwarded closure cell. @@ -300,14 +303,18 @@ pub(crate) fn scan_class_value_roots_mut(visitor: &mut crate::gc::RuntimeRootVis /// `closure_get_dynamic_prop` on the class ShapeId): the class lookup. #[cold] #[inline(never)] -pub(crate) fn class_static_read(ptr: usize, prop: &str) -> f64 { +pub(crate) fn class_static_read(ptr: usize, prop: &str, key: *const crate::StringHeader) -> f64 { // SAFETY: the caller proved a live class closure (its ShapeId). let Some(class_id) = (unsafe { class_closure_id_unchecked(ptr as *const ClosureHeader) }) else { return f64::from_bits(crate::value::TAG_UNDEFINED); }; // The class object is pinned: `ptr` survives the key allocation. - let key = crate::string::js_string_from_bytes(prop.as_ptr(), prop.len() as u32); + let key = if key.is_null() { + crate::string::js_string_from_bytes(prop.as_ptr(), prop.len() as u32) + } else { + key as *mut crate::StringHeader + }; let value = crate::object::field_get_set::class_value_get_field( ptr as *const crate::object::ObjectHeader, key, diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs index b78abaeaf2..7cd6c780b7 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs @@ -130,6 +130,16 @@ pub(crate) fn get_field_by_name_object_tail( if key.is_null() { return JSValue::undefined(); } + // A class constructor: its class lookup with this key header. + if crate::closure::shape::is_class_code( + (*(obj as *const crate::closure::ClosureHeader)).func_ptr, + ) { + if let Some(value) = + super::has_property::class_closure_read_by_key(obj as usize, key) + { + return JSValue::from_bits(value.to_bits()); + } + } let key_ptr = (key as *const u8).add(std::mem::size_of::()); let key_len = (*key).byte_len as usize; let key_bytes = std::slice::from_raw_parts(key_ptr, key_len); diff --git a/crates/perry-runtime/src/object/field_get_set/has_property.rs b/crates/perry-runtime/src/object/field_get_set/has_property.rs index 205b312296..8ea88c89f3 100644 --- a/crates/perry-runtime/src/object/field_get_set/has_property.rs +++ b/crates/perry-runtime/src/object/field_get_set/has_property.rs @@ -1387,6 +1387,25 @@ pub(crate) unsafe fn prototype_value_has_property( /// Get a field by its string key name /// Returns the field value or undefined if the key is not found +/// A class function object read with the caller's own key header (no key +/// string is built): its class lookup. `None` for any other receiver — one +/// ShapeId-word pre-filter; the class shape is sticky and implies the class +/// code pointer. +#[inline] +pub(crate) unsafe fn class_closure_read_by_key( + obj: usize, + key: *const crate::StringHeader, +) -> Option { + let class_id = crate::object::class_value::class_closure_id(obj)?; + let value = super::get_field_by_name::class_value_get_field( + obj as *const crate::object::ObjectHeader, + key, + obj as u64, + class_id, + ); + Some(f64::from_bits(value.bits())) +} + pub(crate) unsafe fn closure_dynamic_prop_by_key( obj: usize, key: *const crate::StringHeader, @@ -1395,7 +1414,7 @@ pub(crate) unsafe fn closure_dynamic_prop_by_key( return None; } let name = crate::string::header_str_checked(key)?; - let val = crate::closure::closure_get_dynamic_prop(obj, name); + let val = crate::closure::closure_get_dynamic_prop_keyed(obj, name, key); // Function methods were already resolved, including a getter or own // slot returning undefined. Do not repeat that read or synthesize a // fallback method over an explicit undefined value (#11175). From 9a9107c61bdc6334eb2eb50ff8bf2030cca1d607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 01:10:18 +0000 Subject: [PATCH 13/29] perf(runtime): one class probe per method-value read; the verdict cache is read in place Exact uprobe counts on a zod-shaped `new` (a constructor doing six `this.m = this.m.bind(this)`) showed the same call graph in both arms. The difference was per-call work: js_class_method_bind asked class_ref_id twice for every ordinary instance (its constructor-ref check, then class_id_from_method_receiver), and after stage 0 each ask runs the pointer pre-filter plus a prototype-ref probe. - class_ref_id dispatches on the tag: an INT32 word takes the legacy decoders, a pointer takes the class-function pre-filter only, anything else is None. - js_class_method_bind asks once and passes the answer on (class_id_from_method_receiver_known). - function_shape_inherits_from_function_prototype read its per-agent verdict cache with Cell::get, which copies the whole 64-entry (512-byte) array on every bind/call/apply. It now indexes in place. zod-shaped `new` with six binds (instr/op, CGU14, base a26c45070 -> this): 36140 -> 36236 (was +449 before this change). bind 2825 -> 2848, fn.call 2341 -> 2314. --- crates/perry-runtime/src/closure/shape.rs | 11 +++++------ crates/perry-runtime/src/object/native_module.rs | 16 +++++++++++++--- .../src/object/native_module/class_ref_values.rs | 12 +++++++++++- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/crates/perry-runtime/src/closure/shape.rs b/crates/perry-runtime/src/closure/shape.rs index a1e19baf6e..f2e91dbf46 100644 --- a/crates/perry-runtime/src/closure/shape.rs +++ b/crates/perry-runtime/src/closure/shape.rs @@ -368,7 +368,9 @@ pub(crate) fn function_shape_inherits_from_function_prototype(id: u32, key: &[u8 _ => return keyed_shape_lacks_key(id, key), }; let slot = (id as usize).wrapping_mul(0x9E37_79B9) >> 26 & (VERDICT_CACHE_LEN - 1); - let cached = VERDICT_CACHE.with(|c| c.get()[slot]); + // Index in place: `Cell::get` would copy the whole 64-entry array. + // SAFETY: this agent's own cell; no reference to it outlives the read. + let cached = VERDICT_CACHE.with(|c| unsafe { (*c.as_ptr())[slot] }); let mask = if cached.0 == id { cached.1 } else { @@ -388,11 +390,8 @@ pub(crate) fn function_shape_inherits_from_function_prototype(id: u32, key: &[u8 } else { 0 }; - VERDICT_CACHE.with(|c| { - let mut all = c.get(); - all[slot] = (id, mask); - c.set(all); - }); + // SAFETY: as above; a single-entry store in place. + VERDICT_CACHE.with(|c| unsafe { (*c.as_ptr())[slot] = (id, mask) }); mask }; mask & bit != 0 diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index a544a6a808..acdc714c81 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -1341,10 +1341,13 @@ pub extern "C" fn js_class_method_bind( // resolves statics-first for constructor refs. PROTOTYPE refs // (`C.prototype.m`) keep the canonical path — the instance method // is exactly what they name. + let receiver_class_ref = class_ref_id(instance); let receiver_is_constructor_ref = - class_ref_id(instance).is_some() && class_prototype_ref_id(instance).is_none(); + receiver_class_ref.is_some() && class_prototype_ref_id(instance).is_none(); if !receiver_is_constructor_ref && bound_native_method_length(name).is_none() { - if let Some(class_id) = class_id_from_method_receiver(instance) { + if let Some(class_id) = + class_id_from_method_receiver_known(instance, receiver_class_ref) + { let private_owner = super::take_private_method_owner_hint(name); if let Some(owner) = private_owner .or_else(|| super::class_registry::method_owner_class_id(class_id, name)) @@ -1703,7 +1706,14 @@ pub(crate) fn canonical_bound_method_receiver(captured: f64) -> f64 { /// non-object allocation (an array, above all) must resolve to `None` rather /// than to whatever its bytes happen to hold at the `class_id` offset. pub(super) fn class_id_from_method_receiver(instance: f64) -> Option { - if let Some(cid) = class_ref_id(instance) { + class_id_from_method_receiver_known(instance, class_ref_id(instance)) +} + +/// [`class_id_from_method_receiver`] for a caller that already asked +/// `class_ref_id(instance)`. +#[inline] +fn class_id_from_method_receiver_known(instance: f64, class_ref: Option) -> Option { + if let Some(cid) = class_ref { return Some(cid); } let jsv = JSValue::from_bits(instance.to_bits()); diff --git a/crates/perry-runtime/src/object/native_module/class_ref_values.rs b/crates/perry-runtime/src/object/native_module/class_ref_values.rs index 23583906ef..fab0d06f12 100644 --- a/crates/perry-runtime/src/object/native_module/class_ref_values.rs +++ b/crates/perry-runtime/src/object/native_module/class_ref_values.rs @@ -45,7 +45,17 @@ pub(crate) fn class_prototype_ref_id(value: f64) -> Option { /// callers that mean only the constructor ask that directly. #[inline] pub(crate) fn class_ref_id(value: f64) -> Option { - super::class_value::class_value_id(value).or_else(|| class_prototype_ref_id(value)) + let bits = value.to_bits(); + match bits >> 48 { + // The legacy immediates: constructor or `C.prototype` reference. + 0x7FFE => super::class_value::class_value_id_bits(bits) + .or_else(|| class_prototype_ref_id(value)), + // A class function object (one pre-filter for any other pointer). + 0x7FFD => super::class_value::class_closure_id( + (bits & crate::value::POINTER_MASK) as usize, + ), + _ => None, + } } pub(crate) unsafe fn metadata_key_to_string(value: f64) -> Option { From 221c633dd2ade557f39723582c82b7fae6b5d19d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 26 Sep 2026 15:56:05 +0000 Subject: [PATCH 14/29] refactor(runtime): a class constructor's own [[Prototype]] is recorded on its function object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 3b. `Object.setPrototypeOf(C, proto)` / `(C, null)` on a class value recorded the link in CLASS_STATIC_PROTOTYPES / CLASS_STATIC_PROTOTYPE_NULLED (cid-keyed, root-scanned). It is now the class function object's recorded [[Prototype]] in its state record ("p"), the same record every function object uses — a traced edge, no table, no root scan. A function (including another class) is now a valid prototype: `Object.setPrototypeOf(Q, P); Q.fromP` read undefined before (the table refused closures). --- .../src/object/class_registry.rs | 2 +- .../src/object/class_registry/gc_roots.rs | 37 ------ .../src/object/class_registry/state.rs | 111 +++++------------- .../object/object_ops/define_properties.rs | 14 +-- 4 files changed, 36 insertions(+), 128 deletions(-) diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index b7f902334e..3239b415fd 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -107,7 +107,7 @@ pub use state::{ CLASS_DYNAMIC_PARENT_VALUE, CLASS_METHOD_BIND_LENGTHS, CLASS_OBJECT_VALUES, CLASS_PARENT_CLOSURES, CLASS_PROTOTYPE_METHOD_NONENUM, CLASS_PROTOTYPE_OBJECTS, CLASS_STATIC_ACCESSORS, CLASS_STATIC_METHODS, CLASS_STATIC_METHOD_BIND_LENGTHS, - CLASS_STATIC_PROTOTYPES, CLASS_STRING_MEMBER_ORDERS, CLASS_SYMBOL_ACCESSORS, + CLASS_STRING_MEMBER_ORDERS, CLASS_SYMBOL_ACCESSORS, CLASS_SYMBOL_MEMBER_ORDERS, CLASS_SYMBOL_METHODS, CLASS_VTABLE_REGISTRY, FUNCTION_CLASS_IDS, REGISTERED_CLASS_IDS, }; diff --git a/crates/perry-runtime/src/object/class_registry/gc_roots.rs b/crates/perry-runtime/src/object/class_registry/gc_roots.rs index 0a5491fc71..eff9940f46 100644 --- a/crates/perry-runtime/src/object/class_registry/gc_roots.rs +++ b/crates/perry-runtime/src/object/class_registry/gc_roots.rs @@ -16,9 +16,6 @@ enum ClassSideTableRootSlot { DeclPrototypeObject { class_id: u32, }, - StaticPrototype { - class_id: u32, - }, ParentClosure { class_id: u32, }, @@ -118,16 +115,6 @@ pub fn scan_class_side_table_roots_mut(visitor: &mut crate::gc::RuntimeRootVisit } }); - CLASS_STATIC_PROTOTYPES.with(|table| { - if let Ok(mut guard) = table.write() { - if let Some(map) = guard.as_mut() { - for proto_addr in map.values_mut() { - visitor.visit_usize_slot(proto_addr); - } - } - } - }); - CLASS_PARENT_CLOSURES.with(|table| { if let Ok(mut guard) = table.write() { if let Some(map) = guard.as_mut() { @@ -267,16 +254,6 @@ fn class_side_table_root_snapshot() -> Vec { } }); - CLASS_STATIC_PROTOTYPES.with(|table| { - if let Ok(guard) = table.read() { - if let Some(map) = guard.as_ref() { - for &class_id in map.keys() { - slots.push(ClassSideTableRootSlot::StaticPrototype { class_id }); - } - } - } - }); - CLASS_PARENT_CLOSURES.with(|table| { if let Ok(guard) = table.read() { if let Some(map) = guard.as_ref() { @@ -402,15 +379,6 @@ fn scan_class_side_table_root_slot( } }); } - ClassSideTableRootSlot::StaticPrototype { class_id } => { - CLASS_STATIC_PROTOTYPES.with(|table| { - if let Ok(mut guard) = table.write() { - if let Some(proto_addr) = guard.as_mut().and_then(|map| map.get_mut(class_id)) { - visitor.visit_usize_slot(proto_addr); - } - } - }); - } ClassSideTableRootSlot::ParentClosure { class_id } => { CLASS_PARENT_CLOSURES.with(|table| { if let Ok(mut guard) = table.write() { @@ -650,11 +618,6 @@ pub(crate) fn test_clear_class_side_table_roots() { *guard = None; } }); - CLASS_STATIC_PROTOTYPES.with(|table| { - if let Ok(mut guard) = table.write() { - *guard = None; - } - }); CLASS_PARENT_CLOSURES.with(|table| { if let Ok(mut guard) = table.write() { *guard = None; diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index 3afeb4a2ef..bc4c2a313c 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -448,37 +448,7 @@ pub(crate) fn class_prototype_object_addr_index_rekey(old: usize, new: usize) { }); } -crate::perry_thread_local! { - /// The CONSTRUCTOR's `[[Prototype]]`, set by `Object.setPrototypeOf(Ctor, obj)` - /// on a declared class (perry represents those as INT32 ClassRefs, not heap - /// Function objects, so they have no closure prototype slot to write). - /// - /// Deliberately its own table. `CLASS_PROTOTYPE_OBJECTS` means "the object - /// INSTANCES of this class inherit from", and the method-dispatch and - /// field-read walks read it for exactly that purpose — parking a - /// constructor-side link there makes `new Ctor()` inherit the constructor's - /// statics and makes prototype-method mirroring write into the user's - /// object. Only the static-side lookups consult this table: - /// `js_object_get_field_by_name`'s ClassRef arm, the generic `in` presence - /// walk, static method dispatch, and `Object.getPrototypeOf`. - /// - /// Effect's `Schema.Opaque` is the motivating shape (`Schema.ts:1887`, - /// `:5874`): `class Opaque {}; Object.setPrototypeOf(Opaque, schema)`, then - /// `class Partial extends Opaque {}` reads `Partial.ast` through the chain. - /// - /// Stored as `usize` for the same Send + Sync reason as the tables above. - pub static CLASS_STATIC_PROTOTYPES: RwLock>> = RwLock::new(None); -} - -crate::perry_thread_local! { - /// Class ids whose constructor `[[Prototype]]` was explicitly set to `null` - /// (`Object.setPrototypeOf(Ctor, null)`). Absence from CLASS_STATIC_PROTOTYPES - /// alone cannot express this: "never linked" must still report the default - /// `Function.prototype`, while an explicit null must report `null`. Holds - /// class ids only, so the collector has nothing to trace here. - pub static CLASS_STATIC_PROTOTYPE_NULLED: RwLock>> = - RwLock::new(None); -} +crate::perry_thread_local! {} crate::perry_thread_local! { /// Lazily materialized `Class.prototype` objects for declared ES classes. @@ -658,77 +628,54 @@ pub(crate) fn class_prototype_object_root_store(class_id: u32, proto_ptr: *mut O super::class_lookup_surface_gen_bump(); } +/// `Object.setPrototypeOf(Ctor, proto)`: the class function object's +/// recorded `[[Prototype]]` (its state record, a traced edge). pub(crate) fn class_static_prototype_root_store(class_id: u32, proto_ptr: *mut ObjectHeader) { if class_id == 0 || proto_ptr.is_null() { return; } - CLASS_STATIC_PROTOTYPES.with(|table| { - let mut guard = table.write().unwrap(); - if guard.is_none() { - *guard = Some(HashMap::new()); - } - guard.as_mut().unwrap().insert(class_id, proto_ptr as usize); - }); - CLASS_STATIC_PROTOTYPE_NULLED.with(|table| { - if let Ok(mut guard) = table.write() { - if let Some(set) = guard.as_mut() { - set.remove(&class_id); - } - } - }); - crate::gc::runtime_write_barrier_root_raw_ptr(proto_ptr); + let bits = crate::value::js_nanbox_pointer(proto_ptr as i64).to_bits(); + crate::closure::closure_set_static_prototype( + crate::object::class_value::class_value_ptr(class_id) as usize, + bits, + ); } +/// `Object.setPrototypeOf(Ctor, null)`. pub(crate) fn class_static_prototype_root_clear(class_id: u32) { if class_id == 0 { return; } - CLASS_STATIC_PROTOTYPES.with(|table| { - if let Ok(mut guard) = table.write() { - if let Some(map) = guard.as_mut() { - map.remove(&class_id); - } - } - }); - CLASS_STATIC_PROTOTYPE_NULLED.with(|table| { - let mut guard = table.write().unwrap(); - if guard.is_none() { - *guard = Some(std::collections::HashSet::new()); - } - guard.as_mut().unwrap().insert(class_id); - }); + crate::closure::closure_set_static_prototype( + crate::object::class_value::class_value_ptr(class_id) as usize, + crate::value::TAG_NULL, + ); } -/// True when `Object.setPrototypeOf(Ctor, null)` explicitly severed the -/// constructor's prototype chain, as opposed to never having linked one. -pub(crate) fn class_static_prototype_is_nulled(class_id: u32) -> bool { +fn class_recorded_prototype_bits(class_id: u32) -> Option { if class_id == 0 { - return false; + return None; } let class_id = crate::object::class_generic_origin(class_id).unwrap_or(class_id); - CLASS_STATIC_PROTOTYPE_NULLED.with(|table| { - table - .read() - .ok() - .and_then(|guard| guard.as_ref().map(|set| set.contains(&class_id))) - .unwrap_or(false) - }) + crate::closure::closure_static_prototype( + crate::object::class_value::class_value_ptr(class_id) as usize + ) +} + +/// True when `Object.setPrototypeOf(Ctor, null)` explicitly severed the +/// constructor's prototype chain, as opposed to never having linked one. +pub(crate) fn class_static_prototype_is_nulled(class_id: u32) -> bool { + class_recorded_prototype_bits(class_id) == Some(crate::value::TAG_NULL) } /// The constructor-side `[[Prototype]]` recorded for `class_id`, or null. pub(crate) fn class_static_prototype(class_id: u32) -> *mut ObjectHeader { - if class_id == 0 { - return std::ptr::null_mut(); - } - let class_id = crate::object::class_generic_origin(class_id).unwrap_or(class_id); - CLASS_STATIC_PROTOTYPES.with(|table| { - if let Ok(read) = table.read() { - if let Some(map) = read.as_ref() { - return map.get(&class_id).copied().unwrap_or(0) as *mut ObjectHeader; - } + match class_recorded_prototype_bits(class_id) { + Some(bits) if bits & crate::value::TAG_MASK == crate::value::POINTER_TAG => { + (bits & crate::value::POINTER_MASK) as *mut ObjectHeader } - std::ptr::null_mut() - }) + _ => std::ptr::null_mut(), + } } pub(crate) fn class_decl_prototype_object_root_store(class_id: u32, proto_ptr: *mut ObjectHeader) { diff --git a/crates/perry-runtime/src/object/object_ops/define_properties.rs b/crates/perry-runtime/src/object/object_ops/define_properties.rs index f962a07f33..74a3a8f297 100644 --- a/crates/perry-runtime/src/object/object_ops/define_properties.rs +++ b/crates/perry-runtime/src/object/object_ops/define_properties.rs @@ -360,19 +360,16 @@ pub extern "C" fn js_object_set_prototype_of(obj_value: f64, proto: f64) -> f64 } } - // Declared ES classes are represented by INT32-tagged ClassRefs rather - // than heap Function objects. Preserve Object.setPrototypeOf on a ClassRef - // as the class object's static prototype. Effect's Schema.Opaque depends - // on this exact shape: + // A class constructor's own [[Prototype]]: recorded on its function object + // (the state record every function object uses). Effect's Schema.Opaque + // depends on this exact shape: // // class Opaque {} // Object.setPrototypeOf(Opaque, schema) // class Partial extends Opaque {} // Partial.ast // - // Ordinary object and closure targets already have prototype side tables, - // but the ClassRef previously fell through as a no-op. Record it in - // CLASS_STATIC_PROTOTYPES — the CONSTRUCTOR-side table. + // It is the CONSTRUCTOR-side link. // // It must not go in CLASS_PROTOTYPE_OBJECTS: that table means "what // INSTANCES of this class inherit from", so parking a constructor link @@ -393,8 +390,9 @@ pub extern "C" fn js_object_set_prototype_of(obj_value: f64, proto: f64) -> f64 // GC root table that the collector later dereferences — a segfault // there, silently hidden on macOS (#1843/#4004/#4665/#4800/#6271). // Require a real, readable GC header instead. + // A function (including another class) is a valid [[Prototype]]: + // the link is a traced edge of the function object, not a table. if !proto_ptr.is_null() - && !crate::closure::is_closure_ptr(proto_ptr as usize) && crate::value::addr_class::is_above_handle_band(proto_ptr as usize) && unsafe { crate::value::addr_class::try_read_gc_header(proto_ptr as usize).is_some() From ea96c7abb035fc34d0894d5b7b6f2363baf58f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 26 Sep 2026 16:13:15 +0000 Subject: [PATCH 15/29] fix(runtime,codegen): a declared static's compiled alias follows the property A statically lowered `C.x` reads (and `C.x = v` writes) the declared static's `@perry_static_*` global; the runtime only kept that global in step on plain writes. After `delete C.x` compiled code kept reading the old value, after `Object.defineProperty(C, "y", { get })` it kept the data value (the class kept the data slot beside the new accessor), and an attribute-only `defineProperty(C, "z", { writable: false })` was dropped, so `C.z = 30` still wrote. - class_static_alias_sync runs after every mutation of a class static: the global holds the value while the key is a plain writable own data property of the class function object, and TAG_HOLE otherwise. - StaticFieldGet: load + `== TAG_HOLE` -> js_class_static_field_get (generic [[Get]] on the class function object). StaticFieldSet on a detached global -> js_class_static_field_put (generic [[Set]]: setter, read-only refusal, or re-creating a deleted static, which re-attaches the alias). - Redefining an own static data property as an accessor removes the data slot. - An attribute-only defineProperty on an existing static keeps the omitted attributes (ValidateAndApplyPropertyDescriptor) and records the new ones; a strict [[Set]] of a read-only static throws. test-files/test_gap_class_statics_alias.ts (== node; red on a26c45070). --- .../src/expr/static_field_meta.rs | 105 +++++++++++++++++- .../src/runtime_decls/strings.rs | 4 + .../src/object/class_registry.rs | 17 ++- .../parent_static/private_and_dynamic.rs | 6 + .../src/object/class_registry/state.rs | 55 ++++++--- .../perry-runtime/src/object/class_value.rs | 35 ++++++ .../src/object/field_set_by_name.rs | 14 +++ .../src/object/object_ops/define_property.rs | 56 +++++++--- test-files/test_gap_class_statics_alias.ts | 24 ++++ 9 files changed, 279 insertions(+), 37 deletions(-) create mode 100644 test-files/test_gap_class_statics_alias.ts diff --git a/crates/perry-codegen/src/expr/static_field_meta.rs b/crates/perry-codegen/src/expr/static_field_meta.rs index 1e271a8272..6d0647ae34 100644 --- a/crates/perry-codegen/src/expr/static_field_meta.rs +++ b/crates/perry-codegen/src/expr/static_field_meta.rs @@ -41,6 +41,90 @@ fn static_block_fns(ctx: &FnCtx<'_>, template: &str) -> Vec { .unwrap_or_default() } +/// The interned name bytes of a static field, as (`@bytes`, len). +fn static_field_name_bytes(ctx: &mut FnCtx<'_>, field_name: &str) -> (String, String) { + let idx = ctx.strings.intern(field_name); + let entry = ctx.strings.entry(idx); + ( + format!("@{}", entry.bytes_global), + entry.byte_len.to_string(), + ) +} + +/// `C.x` through its compiled alias, unless the alias is detached +/// (`TAG_HOLE` — see `class_static_alias_sync`): then the generic [[Get]]. +fn emit_detached_static_get( + ctx: &mut FnCtx<'_>, + class_id: u32, + field_name: &str, + value: &str, +) -> String { + let bits = ctx.block().bitcast_double_to_i64(value); + let detached = ctx + .block() + .icmp_eq(crate::types::I64, &bits, crate::nanbox::TAG_HOLE_I64); + let from_l = ctx.block_label(ctx.current_block); + let slow_idx = ctx.new_block("staticget.detached"); + let join_idx = ctx.new_block("staticget.join"); + let slow_l = ctx.block_label(slow_idx); + let join_l = ctx.block_label(join_idx); + ctx.block().cond_br(&detached, &slow_l, &join_l); + ctx.current_block = slow_idx; + let (bytes, len) = static_field_name_bytes(ctx, field_name); + let slow = ctx.block().call( + DOUBLE, + "js_class_static_field_get", + &[ + (crate::types::I32, &(class_id as i32).to_string()), + (PTR, &bytes), + (crate::types::I64, &len), + ], + ); + let slow_end_l = ctx.block_label(ctx.current_block); + ctx.block().br(&join_l); + ctx.current_block = join_idx; + ctx.block() + .phi(DOUBLE, &[(value, &from_l), (&slow, &slow_end_l)]) +} + +/// `C.x = v`: when the compiled alias is detached, the generic [[Set]] runs in +/// its own block and joins; the caller emits the attached store in the +/// current block and then branches to the returned join block. +fn emit_detached_static_put( + ctx: &mut FnCtx<'_>, + class_id: u32, + field_name: &str, + global_name: &str, + value: &str, +) -> usize { + let current = ctx.block().load(DOUBLE, &format!("@{global_name}")); + let bits = ctx.block().bitcast_double_to_i64(¤t); + let detached = ctx + .block() + .icmp_eq(crate::types::I64, &bits, crate::nanbox::TAG_HOLE_I64); + let slow_idx = ctx.new_block("staticset.detached"); + let fast_idx = ctx.new_block("staticset.attached"); + let join_idx = ctx.new_block("staticset.join"); + let slow_l = ctx.block_label(slow_idx); + let fast_l = ctx.block_label(fast_idx); + let join_l = ctx.block_label(join_idx); + ctx.block().cond_br(&detached, &slow_l, &fast_l); + ctx.current_block = slow_idx; + let (bytes, len) = static_field_name_bytes(ctx, field_name); + ctx.block().call_void( + "js_class_static_field_put", + &[ + (crate::types::I32, &(class_id as i32).to_string()), + (PTR, &bytes), + (crate::types::I64, &len), + (DOUBLE, value), + ], + ); + ctx.block().br(&join_l); + ctx.current_block = fast_idx; + join_idx +} + fn private_static_storage_name(class_id: u32, field_name: &str) -> String { format!("#") } @@ -54,7 +138,13 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let key = (class_name.clone(), field_name.clone()); if let Some(global_name) = ctx.static_field_globals.get(&key).cloned() { let g_ref = format!("@{}", global_name); - Ok(ctx.block().load(DOUBLE, &g_ref)) + let value = ctx.block().load(DOUBLE, &g_ref); + match ctx.class_ids.get(class_name).copied() { + Some(class_id) if !field_name.starts_with('#') => { + Ok(emit_detached_static_get(ctx, class_id, field_name, &value)) + } + _ => Ok(value), + } } else { Ok(double_literal(0.0)) } @@ -67,6 +157,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let v = lower_expr(ctx, value)?; let key = (class_name.clone(), field_name.clone()); let global_name = ctx.static_field_globals.get(&key).cloned(); + // A detached alias (`TAG_HOLE`: deleted / accessor / read-only) + // takes the generic [[Set]] instead of the direct store below. + let join_idx = match (global_name.as_ref(), ctx.class_ids.get(class_name).copied()) { + (Some(global_name), Some(class_id)) if !field_name.starts_with('#') => Some( + emit_detached_static_put(ctx, class_id, field_name, global_name, &v), + ), + _ => None, + }; if let Some(global_name) = global_name.as_ref() { let g_ref = format!("@{}", global_name); // GC_STORE_AUDIT(ROOT): static field global slot is registered as a mutable GC root @@ -105,6 +203,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ], ); } + if let Some(join_idx) = join_idx { + let join_l = ctx.block_label(join_idx); + ctx.block().br(&join_l); + ctx.current_block = join_idx; + } Ok(v) } // Issue #711: dynamic parent-class registration for `class X diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index fad0651d62..b4b24deb27 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -220,6 +220,10 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { // A class constructor as a value: the class's per-agent function object // (`object/class_value.rs`; #11414). module.declare_function("js_class_value", DOUBLE, &[I32]); + // A declared static whose compiled alias is detached (`TAG_HOLE`): the + // generic [[Get]] / [[Set]] on the class function object. + module.declare_function("js_class_static_field_get", DOUBLE, &[I32, PTR, I64]); + module.declare_function("js_class_static_field_put", VOID, &[I32, PTR, I64, DOUBLE]); // Singleton-cached variant for closures with captures, keyed by // `(func_ptr, capture_bits…)`. Args: (func_ptr, capture_count, // captures_ptr — pointer to `capture_count` u64 values). diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index 3239b415fd..c51ae7f992 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -95,21 +95,20 @@ pub(crate) use state::{ class_prototype_method_value_cache_root_store, class_prototype_object_addr_index_contains, class_prototype_object_addr_index_rekey, class_prototype_object_root_store, class_ref_dynamic_prop_root_store, class_register_declared_static_global_slot, - class_static_defined_attrs, class_static_prototype, class_static_prototype_is_nulled, - class_static_prototype_root_clear, class_static_prototype_root_store, - class_static_set_defined_attrs, class_unmark_key_deleted, decl_prototype_identity_id, - global_object_prototype_bits, is_bound_native_constructor_closure_value, - is_non_constructable_builtin_function_value, parent_closure_in_chain, - throw_non_constructable_builtin_function, + class_static_alias_sync, class_static_defined_attrs, class_static_prototype, + class_static_prototype_is_nulled, class_static_prototype_root_clear, + class_static_prototype_root_store, class_static_set_defined_attrs, class_unmark_key_deleted, + decl_prototype_identity_id, global_object_prototype_bits, + is_bound_native_constructor_closure_value, is_non_constructable_builtin_function_value, + parent_closure_in_chain, throw_non_constructable_builtin_function, }; pub use state::{ AccessorDecl, ClassVTable, VTableMethodEntry, CLASS_DECL_PROTOTYPE_OBJECTS, CLASS_DYNAMIC_PARENT_VALUE, CLASS_METHOD_BIND_LENGTHS, CLASS_OBJECT_VALUES, CLASS_PARENT_CLOSURES, CLASS_PROTOTYPE_METHOD_NONENUM, CLASS_PROTOTYPE_OBJECTS, CLASS_STATIC_ACCESSORS, CLASS_STATIC_METHODS, CLASS_STATIC_METHOD_BIND_LENGTHS, - CLASS_STRING_MEMBER_ORDERS, CLASS_SYMBOL_ACCESSORS, - CLASS_SYMBOL_MEMBER_ORDERS, CLASS_SYMBOL_METHODS, CLASS_VTABLE_REGISTRY, FUNCTION_CLASS_IDS, - REGISTERED_CLASS_IDS, + CLASS_STRING_MEMBER_ORDERS, CLASS_SYMBOL_ACCESSORS, CLASS_SYMBOL_MEMBER_ORDERS, + CLASS_SYMBOL_METHODS, CLASS_VTABLE_REGISTRY, FUNCTION_CLASS_IDS, REGISTERED_CLASS_IDS, }; // ── prototype_objects.rs ──────────────────────────────────────────────────── diff --git a/crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs b/crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs index 41a481238e..33bb296b6e 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs @@ -115,6 +115,11 @@ pub(crate) fn register_class_dynamic_static_accessor( return; } let key = dynamic_static_accessor_storage_key(owner, name); + // A property is data OR accessor: redefining an own static data property + // as an accessor removes the data slot from the class function object. + if !is_class_object_ptr(owner as *const u8) { + crate::object::class_value::class_static_remove(class_id, name); + } let existing = crate::object::get_accessor_descriptor(owner, &key).unwrap_or_default(); crate::object::set_accessor_descriptor( owner, @@ -150,6 +155,7 @@ pub(crate) fn register_class_dynamic_static_accessor( } else { class_static_set_defined_attrs(class_id, name, false, enumerable, configurable); } + crate::object::class_registry::class_static_alias_sync(class_id, name); } pub(crate) fn class_dynamic_static_accessor_descriptor( diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index bc4c2a313c..abe062e894 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -105,6 +105,44 @@ pub(crate) fn class_dynamic_prop_root_store(class_id: u32, name: &str, value: f6 } // The class function object's own-property bag (barriered, traced). crate::object::class_value::class_static_set(class_id, name, value); + class_static_alias_sync(class_id, name); +} + +/// Keep a declared static field's compiled alias (its `@perry_static_*` +/// global, which statically lowered `C.x` reads and writes) coherent with the +/// class function object's own property: the global holds the value while +/// `name` is a plain writable own data property, and `TAG_HOLE` otherwise — +/// deleted, an accessor, or read-only — which sends compiled reads and writes +/// to the generic [[Get]] / [[Set]] (`js_class_static_field_get` / `_put`). +/// Called after every mutation of a class static. +pub(crate) fn class_static_alias_sync(class_id: u32, name: &str) { + let Some(slot) = CLASS_DECLARED_STATIC_GLOBAL_SLOTS.with(|slots| { + slots + .borrow() + .get(&class_id) + .and_then(|f| f.get(name)) + .copied() + }) else { + return; + }; + let plain = !class_is_key_deleted(class_id, name) + && class_static_defined_attrs(class_id, name).is_none_or(|(writable, _, _)| writable) + && class_own_static_accessor_ptrs(class_id, name).is_none() + && super::class_dynamic_static_accessor_descriptor( + class_id, + name, + crate::object::class_value::class_value(class_id), + ) + .is_none(); + let value = plain + .then(|| crate::object::class_value::class_static_get(class_id, name)) + .flatten() + .unwrap_or(f64::from_bits(crate::value::TAG_HOLE)); + // SAFETY: codegen only registers addresses of process-lifetime LLVM + // globals, and those slots are mutable GC roots. + unsafe { + crate::gc::runtime_store_root_nanbox_f64_raw_slot(slot as *mut f64, value); + } } /// Associate a declared static field's runtime-table entry with the LLVM @@ -133,20 +171,7 @@ pub(crate) fn class_register_declared_static_global_slot( /// static, through its compiled backing cell as well. This is the terminal /// write used by `C.x`, `C["x"]`, and `C[key]` runtime assignment paths. pub(crate) fn class_ref_dynamic_prop_root_store(class_id: u32, name: &str, value: f64) { - let global_slot = CLASS_DECLARED_STATIC_GLOBAL_SLOTS.with(|slots| { - slots - .borrow() - .get(&class_id) - .and_then(|fields| fields.get(name)) - .copied() - }); - if let Some(global_slot) = global_slot { - // SAFETY: codegen only registers addresses of process-lifetime LLVM - // globals, and those slots are mutable GC roots. - unsafe { - crate::gc::runtime_store_root_nanbox_f64_raw_slot(global_slot as *mut f64, value); - } - } + // The store re-syncs the declared static's compiled alias. class_dynamic_prop_root_store(class_id, name, value); } @@ -200,6 +225,7 @@ pub(crate) fn class_static_set_defined_attrs( .or_default() .insert(name.to_string(), (writable, enumerable, configurable)); }); + class_static_alias_sync(class_id, name); } /// `(writable, enumerable)` if this static key was installed by @@ -223,6 +249,7 @@ pub(crate) fn class_has_own_dynamic_prop(class_id: u32, name: &str) -> bool { pub(crate) fn class_delete_own_dynamic_prop(class_id: u32, name: &str) { crate::object::class_value::class_static_remove(class_id, name); + class_static_alias_sync(class_id, name); } pub(crate) fn class_prototype_method_value_cache_root_store( diff --git a/crates/perry-runtime/src/object/class_value.rs b/crates/perry-runtime/src/object/class_value.rs index 946edf2cea..f1b9a1c33d 100644 --- a/crates/perry-runtime/src/object/class_value.rs +++ b/crates/perry-runtime/src/object/class_value.rs @@ -324,6 +324,41 @@ pub(crate) fn class_static_read(ptr: usize, prop: &str, key: *const crate::Strin f64::from_bits(value.bits()) } +/// A statically lowered `C.x` whose compiled alias is detached (`TAG_HOLE`: +/// the static was deleted, redefined as an accessor or made read-only): the +/// generic [[Get]] on the class function object. +/// +/// # Safety +/// `name_ptr` points at `name_len` bytes of UTF-8 (codegen rodata). +#[no_mangle] +pub unsafe extern "C" fn js_class_static_field_get( + class_id: i32, + name_ptr: *const u8, + name_len: i64, +) -> f64 { + let key = crate::string::js_string_from_bytes(name_ptr, name_len as u32); + let receiver = class_value_ptr(class_id as u32) as *const crate::object::ObjectHeader; + f64::from_bits(crate::object::js_object_get_field_by_name(receiver, key).bits()) +} + +/// A statically lowered `C.x = v` whose compiled alias is detached: the +/// generic [[Set]] on the class function object (a setter, a read-only +/// refusal, or re-creating a deleted static — which re-attaches the alias). +/// +/// # Safety +/// As [`js_class_static_field_get`]. +#[no_mangle] +pub unsafe extern "C" fn js_class_static_field_put( + class_id: i32, + name_ptr: *const u8, + name_len: i64, + value: f64, +) { + let key = crate::string::js_string_from_bytes(name_ptr, name_len as u32); + let receiver = class_value_ptr(class_id as u32) as *mut crate::object::ObjectHeader; + crate::object::js_object_set_field_by_name(receiver, key, value); +} + // --------------------------------------------------------------------------- // Statics: the class function object's OWN properties. // --------------------------------------------------------------------------- diff --git a/crates/perry-runtime/src/object/field_set_by_name.rs b/crates/perry-runtime/src/object/field_set_by_name.rs index dc7a0e5750..d71938261d 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -500,6 +500,20 @@ pub extern "C" fn js_object_set_field_by_name( ); crate::typed_feedback::invalidate_method_change(class_id); } else { + // A read-only own static (defineProperty writable:false): + // strict-mode [[Set]] throws; the value stays. + if has_own_data + && super::class_registry::class_static_defined_attrs(class_id, &name) + .is_some_and(|(writable, _, _)| !writable) + { + let message = format!( + "Cannot assign to read only property '{name}' of function '{}'", + super::class_registry::class_ref_to_string(class_id) + ); + crate::node_submodules::diagnostics::throw_type_error_no_code( + message.as_bytes(), + ); + } // #9526: a declared static has two views: runtime class // property dispatch and the LLVM global used by direct // `C.name` reads. Keep both coherent for every runtime diff --git a/crates/perry-runtime/src/object/object_ops/define_property.rs b/crates/perry-runtime/src/object/object_ops/define_property.rs index 8a2c2e24e3..96b1c789c7 100644 --- a/crates/perry-runtime/src/object/object_ops/define_property.rs +++ b/crates/perry-runtime/src/object/object_ops/define_property.rs @@ -876,7 +876,27 @@ pub extern "C" fn js_object_define_property( let value_key = crate::string::js_string_from_bytes(b"value".as_ptr(), 5); let value_field = js_object_get_field_by_name(desc_ptr as *const ObjectHeader, value_key); - if !value_field.is_undefined() { + let descriptor_value = desc_handle.get_nanbox_f64(); + let has_value = desc_has_field(descriptor_value, b"value"); + // ECMA-262 ValidateAndApplyPropertyDescriptor: an existing own + // static keeps every attribute the descriptor omits (a + // declared `static x` is writable+enumerable+configurable); + // a new key defaults them to false. + let existing_static = super::super::class_prototype_ref_id(obj_value) + .is_none() + .then(|| { + super::super::class_registry::class_own_static_field_value( + target_cid, &name, + ) + }) + .flatten() + .map(|_| { + super::super::class_registry::class_static_defined_attrs( + target_cid, &name, + ) + .unwrap_or((true, true, true)) + }); + if !value_field.is_undefined() || has_value || existing_static.is_some() { // #7190: `C` and `C.prototype` both answer // `class_ref_id` with the SAME class id — the arm this // sits in exists because `C.prototype` maps back to the @@ -899,11 +919,13 @@ pub extern "C" fn js_object_define_property( // `js_class_register_static_field` write to, so the // existing static read path finds it with no new // lookup. - super::super::class_registry::class_dynamic_prop_root_store( - target_cid, - &name, - f64::from_bits(value_field.bits()), - ); + if has_value || existing_static.is_none() { + super::super::class_registry::class_dynamic_prop_root_store( + target_cid, + &name, + f64::from_bits(value_field.bits()), + ); + } // A data descriptor is non-enumerable unless it // says otherwise; a `static x = …` field IS // enumerable, and both share CLASS_DYNAMIC_PROPS. @@ -922,17 +944,25 @@ pub extern "C" fn js_object_define_property( desc_read_field(descriptor_value, b"configurable").bits(), )) != 0 } else { - super::super::class_registry::class_static_defined_attrs( - target_cid, &name, - ) - .map(|(_, _, cfg)| cfg) - .unwrap_or(matches!(name.as_str(), "name" | "length")) + existing_static + .map(|(_, _, cfg)| cfg) + .unwrap_or(matches!(name.as_str(), "name" | "length")) + }; + let writable = if desc_has_field(descriptor_value, b"writable") { + descriptor_writable(descriptor_value) + } else { + existing_static.is_some_and(|(w, _, _)| w) + }; + let enumerable = if desc_has_field(descriptor_value, b"enumerable") { + descriptor_enumerable(descriptor_value) + } else { + existing_static.is_some_and(|(_, e, _)| e) }; super::super::class_registry::class_static_set_defined_attrs( target_cid, &name, - descriptor_writable(descriptor_value), - descriptor_enumerable(descriptor_value), + writable, + enumerable, configurable, ); return obj_value; diff --git a/test-files/test_gap_class_statics_alias.ts b/test-files/test_gap_class_statics_alias.ts new file mode 100644 index 0000000000..705453affc --- /dev/null +++ b/test-files/test_gap_class_statics_alias.ts @@ -0,0 +1,24 @@ +// A declared static field read by compiled code (`C.x`) and by reflection +// must agree after the property is deleted, redefined as an accessor, or made +// read-only; and a constructor's [[Prototype]] set at runtime is honoured. +class C { static x = 1; static y = 2; static z = 3; } +class P { static fromP = "p"; } +delete (C as any).x; +console.log("deleted", C.x, "x" in C, Object.keys(C).join(",")); +Object.defineProperty(C, "y", { get() { return 20; }, configurable: true }); +console.log("accessor", C.y, Object.getOwnPropertyDescriptor(C, "y")!.get !== undefined); +Object.defineProperty(C, "z", { writable: false }); +try { (C as any).z = 30; } catch (e) {} +console.log("readonly", C.z, Object.getOwnPropertyDescriptor(C, "z")!.writable); +class Q {} +Object.setPrototypeOf(Q, { inherited: "yes" }); +console.log("setProto", (Q as any).inherited, Object.getPrototypeOf(Q).inherited); +Object.setPrototypeOf(Q, P); +console.log("setProto class", (Q as any).fromP, Object.getPrototypeOf(Q) === P); +Object.setPrototypeOf(Q, null); +console.log("setProto null", Object.getPrototypeOf(Q), (Q as any).fromP); +class R extends P {} +(P as any).fromP = "p2"; +console.log("inherited static write", R.fromP, Object.getOwnPropertyNames(R).includes("fromP")); +(R as any).fromP = "r"; +console.log("shadow", R.fromP, P.fromP, Object.getOwnPropertyNames(R).includes("fromP")); From 0c5906e02886d34d04f70a4fb733621842d44d85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 26 Sep 2026 21:34:23 +0000 Subject: [PATCH 16/29] refactor(runtime): class static symbols are own symbol properties of the class function object Stage 3e of class constructors as function objects. A class's static Symbol-keyed data properties (`static [sym] = v`, `C[sym] = v`, `Object.defineProperty(C, sym, ...)`) were a class-id-keyed side table, CLASS_STATIC_SYMBOLS plus CLASS_STATIC_SYMBOL_ORDER, with a GC scanner, a sliced root slot and a forwarding rewrite of their own. They now live in the per-object symbol store every object uses (SYMBOL_PROPERTIES, keyed by owner address), owned by the class's pinned function object, whose address never changes. Entries keep creation order there. Both tables, their scanner, the ClassStaticSymbol root slot and its rewrite are deleted. store_class_static_symbol_root, class_static_symbol_lookup and class_static_symbol_keys_for_class keep their signatures over that store; the latch that keeps `instanceof` off the table in symbol-free programs stays. Behaviour fixed (node-comparison fixture test_gap_class_static_symbols.ts): - an inherited `Sub[sym]` / `sym in Sub` reads the parent constructor's static symbol (class_static_symbol_lookup_in_chain); own-property checks stay own; - `Object.defineProperty(C, sym, { value, enumerable: false })` records the attributes (omitted ones false on a new key, retained on an existing one); - `delete C[sym]` deletes it. On a26c45070 the fixture failed from the inherited read on and then stopped at the defineProperty line. --- .../src/gc/tests/global_sink_isolation.rs | 2 +- .../perry-runtime/src/object/delete_rest.rs | 9 ++ .../src/object/field_get_set/has_property.rs | 13 ++- .../src/object/object_ops/define_property.rs | 31 +++++- crates/perry-runtime/src/symbol.rs | 83 +++----------- crates/perry-runtime/src/symbol/gc_roots.rs | 101 +++--------------- crates/perry-runtime/src/symbol/get.rs | 9 +- crates/perry-runtime/src/symbol/properties.rs | 69 ++++++------ .../symbols_tests.rs | 12 ++- scripts/gc_runtime_root_holders.json | 6 ++ test-files/test_gap_class_static_symbols.ts | 31 ++++++ 11 files changed, 169 insertions(+), 197 deletions(-) create mode 100644 test-files/test_gap_class_static_symbols.ts diff --git a/crates/perry-runtime/src/gc/tests/global_sink_isolation.rs b/crates/perry-runtime/src/gc/tests/global_sink_isolation.rs index 654c165d10..b848987566 100644 --- a/crates/perry-runtime/src/gc/tests/global_sink_isolation.rs +++ b/crates/perry-runtime/src/gc/tests/global_sink_isolation.rs @@ -98,7 +98,7 @@ fn the_probe_catches_a_bare_process_global_sink() { // --------------------------------------------------------------------------- /// `symbol::test_clear_symbol_side_table_roots` — `SYMBOL_PROPERTIES`, -/// `SYMBOL_PROPERTY_ATTRS`, `CLASS_STATIC_SYMBOLS`, `SYMBOL_ACCESSOR_PROPERTIES` +/// `SYMBOL_PROPERTY_ATTRS`, `SYMBOL_ACCESSOR_PROPERTIES` /// and the `SYMBOL_POINTERS` rebuild. /// /// The largest reader cluster in the survey behind #7672: 14 tests populate one diff --git a/crates/perry-runtime/src/object/delete_rest.rs b/crates/perry-runtime/src/object/delete_rest.rs index b08382ccd0..58c073f261 100644 --- a/crates/perry-runtime/src/object/delete_rest.rs +++ b/crates/perry-runtime/src/object/delete_rest.rs @@ -870,6 +870,15 @@ pub extern "C" fn js_object_delete_dynamic_value(obj_value: f64, key: f64) -> i3 } // Class-ref receiver (`delete C["m"]`): see `js_object_delete_field_value`. if let Some(class_id) = super::native_module::class_ref_id(obj_value) { + // A symbol key is an own symbol property of the class function object. + if unsafe { crate::symbol::js_is_symbol(key) } != 0 { + return unsafe { + crate::symbol::js_object_delete_symbol_property( + super::class_value::class_value(class_id), + key, + ) + }; + } return js_object_delete_dynamic(class_id as usize as *mut ObjectHeader, key); } if !delete_receiver_is_pointer(obj_value) { diff --git a/crates/perry-runtime/src/object/field_get_set/has_property.rs b/crates/perry-runtime/src/object/field_get_set/has_property.rs index 8ea88c89f3..eb0e59a990 100644 --- a/crates/perry-runtime/src/object/field_get_set/has_property.rs +++ b/crates/perry-runtime/src/object/field_get_set/has_property.rs @@ -6,7 +6,7 @@ use super::*; /// Presence of a Symbol-keyed STATIC member on a class ref, for `sym in Class` /// (#6160). Covers the registration schemes the generic symbol resolver /// (`js_object_get_symbol_property`, which only reads the data-valued -/// CLASS_STATIC_SYMBOLS table) skips: +/// class function object's static symbols) skips: /// * user computed-symbol methods/accessors (`static [S]() {}`, /// `static get [S]()`) → CLASS_SYMBOL_METHODS / CLASS_SYMBOL_ACCESSORS; /// * `static [Symbol.hasInstance]` → the lifted per-class has-instance hook; @@ -370,7 +370,7 @@ pub extern "C" fn js_object_has_property(obj: f64, key: f64) -> f64 { // #6160: `Symbol in Class` where the member is a Symbol-keyed STATIC member // that registers through a scheme the generic symbol resolver below // (`js_object_get_symbol_property`) does not consult — it only sees the - // data-valued CLASS_STATIC_SYMBOLS table. `class_ref_has_symbol_member` + // data-valued class static symbols. `class_ref_has_symbol_member` // presence-checks the method/accessor and well-known static registrations, // so `sym in Class` matches Node even though those members dispatch through // dedicated call paths. Presence-only: `in` is [[HasProperty]], never [[Get]]. @@ -407,13 +407,18 @@ pub extern "C" fn js_object_has_property(obj: f64, key: f64) -> f64 { } // Refs #420 / #618: `Symbol in ClassRef` — drizzle's `entityKind in cls`. - // Class refs are INT32-tagged. Check CLASS_STATIC_SYMBOLS for symbol + // Class refs are INT32-tagged. Check the class's static symbols for symbol // keys and CLASS_DYNAMIC_PROPS for string keys. { let bits = obj.to_bits(); if let Some(class_id) = crate::object::class_value::legacy_class_value_word(bits) { // Symbol key path. - if crate::symbol::class_static_symbol_lookup(class_id, key).is_some() { + let found = if crate::object::class_prototype_ref_id(obj).is_some() { + crate::symbol::class_static_symbol_lookup(class_id, key) + } else { + crate::symbol::class_static_symbol_lookup_in_chain(class_id, key) + }; + if found.is_some() { return nanbox_true; } // #6149: string key on a class ref (`"prototype" in C`, diff --git a/crates/perry-runtime/src/object/object_ops/define_property.rs b/crates/perry-runtime/src/object/object_ops/define_property.rs index 96b1c789c7..2bc2a3ef52 100644 --- a/crates/perry-runtime/src/object/object_ops/define_property.rs +++ b/crates/perry-runtime/src/object/object_ops/define_property.rs @@ -796,7 +796,7 @@ pub extern "C" fn js_object_define_property( // `Object.defineProperty(C, Symbol.hasInstance, { value: fn })` (and // any symbol-keyed static define on a class): `metadata_key_to_string` // can't stringify a Symbol, so the value would be silently dropped. - // Route it into the class static-symbol table (CLASS_STATIC_SYMBOLS) — + // Route it into the class static symbols (the class function object's own symbol properties) — // the same table `static [Symbol.hasInstance]` registers into and that // `js_instanceof` consults — so `x instanceof C` honors the user hook // (zod 4 installs its brand-check `@@hasInstance` exactly this way). @@ -805,6 +805,8 @@ pub extern "C" fn js_object_define_property( // non-`undefined`: `Object.defineProperty(C, sym, { value: undefined })` // must still register an own entry. A generic redefine like // `{ enumerable: true }` (no `value`) leaves any existing entry intact. + let existed = + crate::symbol::class_static_symbol_lookup(target_cid, key_value).is_some(); if desc_has_field(descriptor_value, b"value") { let value_field = desc_read_field(descriptor_value, b"value"); crate::symbol::js_class_register_static_symbol( @@ -813,6 +815,33 @@ pub extern "C" fn js_object_define_property( f64::from_bits(value_field.bits()), ); } + // ValidateAndApplyPropertyDescriptor: omitted attributes are + // false on a new property and retained on an existing one. + let owner = crate::object::class_value::class_value_ptr(target_cid) as usize; + let sym_key = crate::symbol::sym_key_from_f64(key_value); + if crate::symbol::class_static_symbol_lookup(target_cid, key_value).is_some() { + let prior = crate::symbol::get_symbol_property_attrs(owner, sym_key) + .unwrap_or(crate::object::PropertyAttrs::new(existed, existed, existed)); + let descriptor_value = desc_handle.get_nanbox_f64(); + let pick = |field: &[u8], cur: bool| { + if desc_has_field(descriptor_value, field) { + crate::value::js_is_truthy(f64::from_bits( + desc_read_field(descriptor_value, field).bits(), + )) != 0 + } else { + cur + } + }; + crate::symbol::set_symbol_property_attrs( + owner, + sym_key, + crate::object::PropertyAttrs::new( + pick(b"writable", prior.writable()), + pick(b"enumerable", prior.enumerable()), + pick(b"configurable", prior.configurable()), + ), + ); + } return obj_value; } if let Some(name) = super::super::metadata_key_to_string(key_value) { diff --git a/crates/perry-runtime/src/symbol.rs b/crates/perry-runtime/src/symbol.rs index 66ea969030..3fb0f832bd 100644 --- a/crates/perry-runtime/src/symbol.rs +++ b/crates/perry-runtime/src/symbol.rs @@ -54,7 +54,8 @@ pub(crate) use properties::{ symbol_property_is_non_writable, symbol_property_root_bits, }; pub use properties::{ - class_static_symbol_lookup, js_class_register_static_symbol, js_object_has_own_symbol, + class_static_symbol_lookup, class_static_symbol_lookup_in_chain, + js_class_register_static_symbol, js_object_has_own_symbol, js_object_literal_infer_computed_function_name, js_object_set_method_by_name, js_object_set_symbol_method, js_object_set_symbol_property, }; @@ -959,22 +960,9 @@ pub(crate) fn release_symbol_tables_in_freed_ranges( changed |= map.len() != before; } } - // Class ids are process-global, but a member a dying thread stored holds - // its symbol and/or value. The symbol is deliberately NOT dereferenced: a - // `gc_malloc`'d symbol may already have been freed by the thread's - // `MallocState` destructor. `CLASS_STATIC_SYMBOL_ORDER` therefore keeps - // the removed member's symbol id; ids are monotonic and never reissued, - // so a stale id can only cost a few bytes, never a wrong position. - { - let mut guard = CLASS_STATIC_SYMBOLS - .lock() - .unwrap_or_else(PoisonError::into_inner); - if let Some(map) = guard.as_mut() { - let before = map.len(); - map.retain(|&(_, sym), bits| !freed.contains(sym) && !freed.holds_bits(*bits)); - changed |= map.len() != before; - } - } + // A class's static symbol members are own symbol properties of its + // function object (`SYMBOL_PROPERTIES`, owner = that object), which lives + // in the agent's heap: the owner-keyed pass above releases them. if changed { symbol_property_ic_epoch_bump(); } @@ -1037,15 +1025,11 @@ pub fn symbol_property_tables_hold_for_test(owner: usize, sym: usize) -> (bool, (props, attrs) } -/// Test probe (#11471): does class `class_id` hold a static member under the -/// symbol at `sym`? +/// Test probe (#11471): the owner key class `class_id`'s static symbol +/// members are stored under in the calling agent — its function object. #[doc(hidden)] -pub fn class_static_symbol_held_for_test(class_id: u32, sym: usize) -> bool { - CLASS_STATIC_SYMBOLS - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .as_ref() - .is_some_and(|map| map.contains_key(&(class_id, sym))) +pub fn class_static_symbol_owner_for_test(class_id: u32) -> usize { + crate::object::class_value::class_value_ptr(class_id) as usize } // Monotonic id counter for fresh symbols. Not thread-safe per-thread but @@ -1284,56 +1268,21 @@ pub(crate) fn store_object_symbol_property_root( /// Idle until a class declares a static Symbol-keyed member. /// -/// `js_instanceof` consults `CLASS_STATIC_SYMBOLS` for a `Symbol.hasInstance` +/// `js_instanceof` consults a class's static symbols for a `Symbol.hasInstance` /// override on EVERY evaluation, which meant a process-global `Mutex` plus a /// SipHash probe of an empty map for every `x instanceof C` in a program that /// never mentions a Symbol (#7769). pub(crate) static CLASS_STATIC_SYMBOLS_LATCH: crate::registry_latch::RegistryLatch = crate::registry_latch::RegistryLatch::new(); +/// A class's static Symbol-keyed data property (`static [sym] = v`, +/// `C[sym] = v`): an own symbol property of the class's function object, in +/// the same per-object store every object uses (the object is pinned, so its +/// address is a stable owner key). pub(crate) fn store_class_static_symbol_root(class_id: u32, sym_key: usize, value_bits: u64) { - note_symbol_key_installed(sym_key); CLASS_STATIC_SYMBOLS_LATCH.arm(); - let symbol_id = unsafe { (*(sym_key as *const SymbolHeader)).id }; - let created; - { - let mut guard = crate::gc::lock_gc_root_registry(&CLASS_STATIC_SYMBOLS); - if guard.is_none() { - *guard = Some(HashMap::new()); - } - created = guard - .as_mut() - .unwrap() - .insert((class_id, sym_key), value_bits) - .is_none(); - } - if created { - let mut order = CLASS_STATIC_SYMBOL_ORDER.lock().unwrap(); - if order.is_none() { - *order = Some(HashMap::new()); - } - order - .as_mut() - .unwrap() - .entry(class_id) - .or_default() - .push(symbol_id); - } - publish_symbol_side_table_root_edges(sym_key, value_bits); -} - -per_test_global! { - /// Class-id-keyed side table for static Symbol-keyed properties. - /// drizzle's `static [entityKind] = "Table"` registers - /// (class_id, sym_ptr) → value here at module init via - /// `js_class_register_static_symbol`. Consulted by `js_object_has_own` - /// when the receiver is a class identifier (NaN-boxed INT32_TAG). - /// Refs #420. - static CLASS_STATIC_SYMBOLS: Mutex>> = Mutex::new(None); - - /// Symbol-id creation order for static symbol data properties. IDs are - /// stable across moving GC, unlike the pointer keys in the value table. - static CLASS_STATIC_SYMBOL_ORDER: Mutex>>> = Mutex::new(None); + let owner = crate::object::class_value::class_value_ptr(class_id) as usize; + store_object_symbol_property_root(owner, sym_key, value_bits); } #[cfg(test)] diff --git a/crates/perry-runtime/src/symbol/gc_roots.rs b/crates/perry-runtime/src/symbol/gc_roots.rs index 8c05be3014..0366d57261 100644 --- a/crates/perry-runtime/src/symbol/gc_roots.rs +++ b/crates/perry-runtime/src/symbol/gc_roots.rs @@ -24,7 +24,6 @@ pub fn scan_symbol_side_table_roots_mut(visitor: &mut crate::gc::RuntimeRootVisi scan_symbol_property_roots_mut(visitor); scan_symbol_property_attrs_mut(visitor); accessors::scan_symbol_accessor_roots_mut(visitor); - scan_class_static_symbol_roots_mut(visitor); scan_symbol_pointer_metadata_roots_mut(visitor); } @@ -86,31 +85,6 @@ fn scan_symbol_property_attrs_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_ } } -fn scan_class_static_symbol_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { - let mut key_rewrites = Vec::new(); - let mut guard = crate::gc::lock_gc_root_registry(&CLASS_STATIC_SYMBOLS); - let Some(map) = guard.as_mut() else { - return; - }; - - for (class_id, old_sym_key) in map.keys().copied().collect::>() { - let Some(value_bits) = map.get_mut(&(class_id, old_sym_key)) else { - continue; - }; - let mut new_sym_key = old_sym_key; - if visitor.visit_usize_slot(&mut new_sym_key) && new_sym_key != old_sym_key { - key_rewrites.push(((class_id, old_sym_key), (class_id, new_sym_key))); - } - visitor.visit_nanbox_u64_slot(value_bits); - } - - for (old_key, new_key) in key_rewrites { - if let Some(value_bits) = map.remove(&old_key) { - map.insert(new_key, value_bits); - } - } -} - fn scan_symbol_pointer_metadata_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { let mut rewrites = Vec::new(); let mut guard = crate::gc::lock_gc_root_registry(&SYMBOL_POINTERS); @@ -137,7 +111,6 @@ enum SymbolSideTableRootSlot { SymbolPropertyEntry { owner: usize, sym_key: usize }, SymbolPropertyAttrs { owner: usize, sym_key: usize }, SymbolAccessorProperty { owner: usize, sym_key: usize }, - ClassStaticSymbol { class_id: u32, sym_key: usize }, SymbolPointer { ptr: usize }, } @@ -197,15 +170,6 @@ fn symbol_side_table_root_snapshot() -> Vec { slots.push(SymbolSideTableRootSlot::SymbolAccessorProperty { owner, sym_key }); } - { - let guard = crate::gc::lock_gc_root_registry(&CLASS_STATIC_SYMBOLS); - if let Some(map) = guard.as_ref() { - for &(class_id, sym_key) in map.keys() { - slots.push(SymbolSideTableRootSlot::ClassStaticSymbol { class_id, sym_key }); - } - } - } - { let guard = crate::gc::lock_gc_root_registry(&SYMBOL_POINTERS); if let Some(set) = guard.as_ref() { @@ -257,9 +221,6 @@ fn scan_symbol_side_table_root_slot( SymbolSideTableRootSlot::SymbolPropertyAttrs { owner, sym_key } => { rewrite_symbol_property_attrs_if_forwarded(visitor, owner, sym_key); } - SymbolSideTableRootSlot::ClassStaticSymbol { class_id, sym_key } => { - rewrite_class_static_symbol_entry_if_forwarded(visitor, class_id, sym_key); - } SymbolSideTableRootSlot::SymbolPointer { ptr } => { rewrite_symbol_pointer_metadata_if_forwarded(visitor, ptr); } @@ -312,28 +273,6 @@ fn rewrite_symbol_property_attrs_if_forwarded( } } -fn rewrite_class_static_symbol_entry_if_forwarded( - visitor: &mut crate::gc::RuntimeRootVisitor<'_>, - class_id: u32, - sym_key: usize, -) { - let mut guard = crate::gc::lock_gc_root_registry(&CLASS_STATIC_SYMBOLS); - let Some(map) = guard.as_mut() else { - return; - }; - let Some(value_bits) = map.get_mut(&(class_id, sym_key)) else { - return; - }; - let mut new_sym_key = sym_key; - let moved = visitor.visit_usize_slot(&mut new_sym_key); - visitor.visit_nanbox_u64_slot(value_bits); - if moved && new_sym_key != sym_key { - if let Some(value_bits) = map.remove(&(class_id, sym_key)) { - map.insert((class_id, new_sym_key), value_bits); - } - } -} - fn rewrite_symbol_pointer_metadata_if_forwarded( visitor: &mut crate::gc::RuntimeRootVisitor<'_>, ptr: usize, @@ -355,8 +294,6 @@ fn rewrite_symbol_pointer_metadata_if_forwarded( pub(crate) fn test_clear_symbol_side_table_roots() { *crate::gc::lock_gc_root_registry(&SYMBOL_PROPERTIES) = None; *crate::gc::lock_gc_root_registry(&SYMBOL_PROPERTY_ATTRS) = None; - *crate::gc::lock_gc_root_registry(&CLASS_STATIC_SYMBOLS) = None; - *CLASS_STATIC_SYMBOL_ORDER.lock().unwrap() = None; accessors::test_clear_symbol_accessor_roots(); let mut persistent = Vec::new(); @@ -421,19 +358,20 @@ pub(crate) fn test_symbol_property_owner_exists(owner: usize) -> bool { #[cfg(test)] pub(crate) fn test_seed_class_static_symbol_root(class_id: u32, sym_key: usize, value_bits: u64) { if class_id != 0 && sym_key != 0 { - // Root-scanner tests deliberately use synthetic addresses, including - // an unaligned sentinel. Seed only the root table they exercise; - // production registration additionally reads SymbolHeader::id for - // [[OwnPropertyKeys]] ordering and therefore requires a real Symbol. + // Root-scanner tests use synthetic symbol addresses: seed the owner- + // keyed store directly (the production path also records the key's + // installation, which reads a real SymbolHeader). CLASS_STATIC_SYMBOLS_LATCH.arm(); - let mut guard = crate::gc::lock_gc_root_registry(&CLASS_STATIC_SYMBOLS); + let owner = crate::object::class_value::class_value_ptr(class_id) as usize; + let mut guard = crate::gc::lock_gc_root_registry(&SYMBOL_PROPERTIES); if guard.is_none() { - *guard = Some(HashMap::new()); + *guard = Some(new_ptr_hash_map()); + } + let entries = guard.as_mut().unwrap().entry(owner).or_default(); + match entries.iter_mut().find(|(key, _)| *key == sym_key) { + Some(entry) => entry.1 = value_bits, + None => entries.push((sym_key, value_bits)), } - guard - .as_mut() - .unwrap() - .insert((class_id, sym_key), value_bits); drop(guard); publish_symbol_side_table_root_edges(sym_key, value_bits); } @@ -441,24 +379,17 @@ pub(crate) fn test_seed_class_static_symbol_root(class_id: u32, sym_key: usize, #[cfg(test)] pub(crate) fn test_class_static_symbol_root_bits(class_id: u32, sym_key: usize) -> Option { - let guard = crate::gc::lock_gc_root_registry(&CLASS_STATIC_SYMBOLS); - guard - .as_ref() - .and_then(|map| map.get(&(class_id, sym_key)).copied()) + let owner = crate::object::class_value::class_value_ptr(class_id) as usize; + super::symbol_property_root_bits(owner, sym_key) } #[cfg(test)] pub(crate) fn test_class_static_symbol_roots_for_class(class_id: u32) -> Vec<(usize, u64)> { - let guard = crate::gc::lock_gc_root_registry(&CLASS_STATIC_SYMBOLS); + let owner = crate::object::class_value::class_value_ptr(class_id) as usize; + let guard = crate::gc::lock_gc_root_registry(&SYMBOL_PROPERTIES); guard .as_ref() - .map(|map| { - map.iter() - .filter_map(|(&(cid, sym_key), &value_bits)| { - (cid == class_id).then_some((sym_key, value_bits)) - }) - .collect() - }) + .and_then(|map| map.get(&owner).cloned()) .unwrap_or_default() } diff --git a/crates/perry-runtime/src/symbol/get.rs b/crates/perry-runtime/src/symbol/get.rs index 5f1416a449..629a384d86 100644 --- a/crates/perry-runtime/src/symbol/get.rs +++ b/crates/perry-runtime/src/symbol/get.rs @@ -721,7 +721,7 @@ pub(crate) unsafe fn js_object_get_symbol_property_with_receiver( } return f64::from_bits(TAG_UNDEFINED); } - // Check CLASS_STATIC_SYMBOLS first when receiver is a class ref + // Check the class's static symbols first when receiver is a class ref // (top16 == 0x7FFE, INT32_TAG). let bits = obj_f64.to_bits(); if let Some(class_id) = crate::object::class_value::legacy_class_value_word(bits) { @@ -733,7 +733,12 @@ pub(crate) unsafe fn js_object_get_symbol_property_with_receiver( return v; } } - if let Some(vb) = class_static_symbol_lookup(class_id, sym_f64) { + let static_lookup = if crate::object::class_prototype_ref_id(obj_f64).is_some() { + class_static_symbol_lookup(class_id, sym_f64) + } else { + super::class_static_symbol_lookup_in_chain(class_id, sym_f64) + }; + if let Some(vb) = static_lookup { return f64::from_bits(vb); } // #9101: statically-known well-known-symbol METHODS are registered diff --git a/crates/perry-runtime/src/symbol/properties.rs b/crates/perry-runtime/src/symbol/properties.rs index fc8a9ff8d4..5724c6a455 100644 --- a/crates/perry-runtime/src/symbol/properties.rs +++ b/crates/perry-runtime/src/symbol/properties.rs @@ -525,7 +525,7 @@ pub unsafe extern "C" fn js_object_set_symbol_property( // heap address — `set_symbol_property` keys the own-symbol side table by // `obj_key_from_f64`, which returns 0 for a non-pointer receiver, so the // write was silently dropped and `sym in C` / `C[sym]` came back undefined. - // Store it as a static Symbol-keyed member (CLASS_STATIC_SYMBOLS), the same + // Store it as a static Symbol-keyed member of the class function object, the same // table `static [sym] = v` uses and that the class-ref arms of // `js_object_get_symbol_property` / `js_object_has_property` already read. if let Some(class_id) = crate::object::class_ref_id(obj_f64) { @@ -629,48 +629,53 @@ pub fn class_static_symbol_lookup(class_id: u32, sym_f64: f64) -> Option { #[inline(never)] fn class_static_symbol_lookup_slow(class_id: u32, sym_f64: f64) -> Option { - unsafe { - let sym_key = sym_key_from_f64(sym_f64); - if class_id == 0 || sym_key == 0 { - return None; + let sym_key = unsafe { sym_key_from_f64(sym_f64) }; + if class_id == 0 || sym_key == 0 { + return None; + } + let owner = crate::object::class_value::class_value_ptr(class_id) as usize; + symbol_property_root_bits(owner, sym_key) +} + +/// [`class_static_symbol_lookup`] up the class's constructor chain: a +/// subclass constructor inherits its parent's static symbol properties +/// (its [[Prototype]] is the parent constructor). +pub fn class_static_symbol_lookup_in_chain(class_id: u32, sym_f64: f64) -> Option { + if super::CLASS_STATIC_SYMBOLS_LATCH.is_idle() { + return None; + } + let mut cid = class_id; + let mut depth = 0; + while cid != 0 && depth < 64 { + if let Some(bits) = class_static_symbol_lookup_slow(cid, sym_f64) { + return Some(bits); } - let guard = crate::gc::lock_gc_root_registry(&CLASS_STATIC_SYMBOLS); - guard - .as_ref() - .and_then(|m| m.get(&(class_id, sym_key)).copied()) + cid = match crate::object::get_parent_class_id(cid) { + Some(p) if p != cid => p, + _ => return None, + }; + depth += 1; } + None } +/// A class's own static symbol data keys, in creation order. pub(crate) fn class_static_symbol_keys_for_class(class_id: u32) -> Vec { - let guard = crate::gc::lock_gc_root_registry(&CLASS_STATIC_SYMBOLS); - let mut keys: Vec = guard - .as_ref() - .map(|map| { - map.keys() - .filter_map(|&(cid, sym_key)| (cid == class_id).then_some(sym_key)) - .collect() - }) - .unwrap_or_default(); - drop(guard); - let order = CLASS_STATIC_SYMBOL_ORDER.lock().unwrap(); - crate::cold_sort::sort_by_key(&mut keys, |sym_key| unsafe { - let symbol_id = (*sym_key as *const SymbolHeader) - .as_ref() - .map_or(u64::MAX, |symbol| symbol.id); - let position = order - .as_ref() - .and_then(|all| all.get(&class_id)) - .and_then(|ids| ids.iter().position(|id| *id == symbol_id)); - (position.unwrap_or(usize::MAX), symbol_id) - }); - keys + if class_id == 0 { + return Vec::new(); + } + let owner = crate::object::class_value::class_value_ptr(class_id) as usize; + clone_symbol_entries_for_obj_ptr(owner) + .into_iter() + .map(|(sym_key, _)| sym_key) + .collect() } /// `Object.prototype.hasOwnProperty.call(obj, sym)` for Symbol keys. /// Refs #420 — drizzle's `is(value, type)` checks entityKind which is a Symbol. /// /// When `obj` is an INT32-tagged class ref, also consult -/// `CLASS_STATIC_SYMBOLS` for static-Symbol-keyed declarations. +/// the class function object's static symbol properties. #[no_mangle] pub unsafe extern "C" fn js_object_has_own_symbol(obj_f64: f64, sym_f64: f64) -> bool { let bits = obj_f64.to_bits(); diff --git a/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs b/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs index 6f17e5aa49..22d0feb648 100644 --- a/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs +++ b/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs @@ -52,7 +52,7 @@ fn addr_of(value: f64) -> usize { #[test] fn thread_exit_releases_the_threads_symbol_side_table_entries() { const STATIC_SYMBOL_CLASS: u32 = 0x0B11_4711; - let ((owner, sym), alive) = std::thread::spawn(|| { + let ((owner, class_owner, sym), alive) = std::thread::spawn(|| { use perry_runtime::symbol as s; let scope = RuntimeHandleScope::new(); let sym = scope.root_nanbox_f64(unsafe { s::js_symbol_new(string_value("t11471")) }); @@ -100,7 +100,8 @@ fn thread_exit_releases_the_threads_symbol_side_table_entries() { sym3.get_nanbox_f64(), js_nanbox_pointer(accessor.get_raw_mut_ptr::() as i64), ); - // static [sym] = [] on a (process-global) class id (CLASS_STATIC_SYMBOLS). + // static [sym] = [] on a class id: an own symbol property of the class's + // function object, which this thread's agent mints in its own heap. unsafe { s::js_class_register_static_symbol( STATIC_SYMBOL_CLASS, @@ -110,6 +111,7 @@ fn thread_exit_releases_the_threads_symbol_side_table_entries() { }; let owner = obj.get_raw_mut_ptr::() as usize; + let class_owner = s::class_static_symbol_owner_for_test(STATIC_SYMBOL_CLASS); let (sym, sym2, sym3) = ( addr_of(sym.get_nanbox_f64()), addr_of(sym2.get_nanbox_f64()), @@ -119,9 +121,9 @@ fn thread_exit_releases_the_threads_symbol_side_table_entries() { s::symbol_property_tables_hold_for_test(owner, sym).0, s::symbol_property_tables_hold_for_test(owner, sym2).1, s::symbol_accessor_held_for_test(owner, sym3), - s::class_static_symbol_held_for_test(STATIC_SYMBOL_CLASS, sym), + s::symbol_property_tables_hold_for_test(class_owner, sym).0, ]; - ((owner, [sym, sym2, sym3]), alive) + ((owner, class_owner, [sym, sym2, sym3]), alive) }) .join() .unwrap(); @@ -144,7 +146,7 @@ fn thread_exit_releases_the_threads_symbol_side_table_entries() { "a dead thread's symbol accessor outlived its heap" ); assert!( - !s::class_static_symbol_held_for_test(STATIC_SYMBOL_CLASS, sym[0]), + !s::symbol_property_tables_hold_for_test(class_owner, sym[0]).0, "a dead thread's class-static symbol member outlived its heap" ); } diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 2ab0ac3b9b..c019d62fe5 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -2742,6 +2742,12 @@ "name": "ADD_SITES", "verdict": "not_a_gc_pointer", "why": "The registry of static-key store sites that published a key-add memo: each entry is the address of a site record, a module global emitted by codegen (`@perry_ic_N_packed_set`, `[4 x i64]`) that lives for the process, or a leaked Box in a unit test. A site record holds only numbers (ShapeIds, a slot, a generation, and a pointer to a Box-leaked way array of the same numbers), never a GC heap address or a NaN-boxed value. The registry is walked after every full trace only to NOTE the named ShapeIds as cache carriers (`note_packed_add_carriers`); it neither marks nor rewrites anything." + }, + { + "file": "crates/perry-runtime/src/closure/shape.rs", + "name": "CLASS_SHAPE", + "verdict": "not_a_gc_pointer", + "why": "This agent's class-constructor ShapeId (a u32 shape-directory index minted once and pinned as an external shape carrier), never a heap reference." } ], "_FRONTIER_README": "Identity-pinned debt ratchet over new perry-ui* candidates and otherwise-unclassified core raw/Perry TLS declarations (see the census docstring, \u201cThe identity-pinned frontier\u201d). A new uncovered holder fails until it is scanned, receives a researched holders verdict, or is deliberately pinned as debt. Moving a researched false positive to holders graduates it from this list. A fixed or classified holder makes its old frontier pin stale, so the receipt must be deleted.", diff --git a/test-files/test_gap_class_static_symbols.ts b/test-files/test_gap_class_static_symbols.ts new file mode 100644 index 0000000000..bad911bc27 --- /dev/null +++ b/test-files/test_gap_class_static_symbols.ts @@ -0,0 +1,31 @@ +// Class static Symbol-keyed data properties are own symbol properties of the +// class function object: declared, runtime-added, defined, deleted, inherited. +const tag = Symbol("tag"); +const extra = Symbol("extra"); +const defined = Symbol("defined"); +class C { + static [tag] = "C-tag"; + static plain = 1; +} +class Sub extends C {} +console.log("declared", (C as any)[tag], tag in C, Object.prototype.hasOwnProperty.call(C, tag)); +(C as any)[extra] = "runtime"; +console.log("runtime", (C as any)[extra], extra in C); +console.log("symbols", Object.getOwnPropertySymbols(C).map(String).join(",")); +console.log("inherited", (Sub as any)[tag], tag in Sub, Object.prototype.hasOwnProperty.call(Sub, tag)); +console.log("sub symbols", Object.getOwnPropertySymbols(Sub).length); +Object.defineProperty(C, defined, { value: 42, enumerable: false }); +console.log("defined", (C as any)[defined], Object.getOwnPropertyDescriptor(C, defined)!.enumerable); +console.log("ownKeys", Reflect.ownKeys(C).map(String).sort().join(",")); +delete (C as any)[extra]; +console.log("deleted", (C as any)[extra], extra in C, Object.getOwnPropertySymbols(C).map(String).join(",")); +(Sub as any)[tag] = "Sub-tag"; +console.log("shadow", (Sub as any)[tag], (C as any)[tag], Object.getOwnPropertySymbols(Sub).map(String).join(",")); +class Even { + static [Symbol.hasInstance](v: unknown) { return typeof v === "number" && v % 2 === 0; } +} +console.log("hasInstance", (4 as any) instanceof Even, (3 as any) instanceof Even); +class Branded { static [Symbol.for("brand")] = "b"; } +console.log("registered", (Branded as any)[Symbol.for("brand")], Symbol.for("brand") in Branded); +const is = (v: any, k: any) => Object.prototype.hasOwnProperty.call(k, tag) && v instanceof k; +console.log("is", is(new C(), C), is(new Sub(), Sub)); From a2c21fbcdb9ad4a41c4d736a8c0a74469087633a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 20:46:39 +0000 Subject: [PATCH 17/29] fix(runtime): a class constructor's name and length are own data of its function object ClassDefinitionEvaluation's SetFunctionLength / SetFunctionName: `length` and `name` are minted into the class function object's own-property object with it (in that order, {writable: false, enumerable: false, configurable: true}), so `C.name` and `x.constructor.name` are one lookup in that object's shape instead of the class-registry walk plus a fresh string per read. A static method or accessor of the same name owns the key instead (a registration after the object exists removes the intrinsic); a static field replaces it with ordinary attributes; a re-registered name/length updates it. The class read answers own data from the object first while no per-evaluation class object exists (CLASS_OBJECT_EVER). A deleted own key now continues on the class's [[Prototype]] (recorded prototype, parent class, parent function, Function.prototype): `delete E.name; E.name` is "" and `delete L.length` reads 0 as in Node (was undefined); getOwnPropertyNames drops a deleted length/name. zmicro (instr/op, base aca0fc81e -> this): C.name 4566 -> 856, x.constructor.name 8459 -> 4795, factory read 1354 -> 1096. --- .../11414-class-name-length-own-data.md | 9 + .../src/object/class_registry.rs | 6 +- .../src/object/class_registry/class_meta.rs | 22 +- .../object/class_registry/parent_static.rs | 78 +++---- .../class_registry/prototype_methods.rs | 4 + .../src/object/class_registry/registration.rs | 33 +-- .../src/object/class_registry/state.rs | 26 +++ .../perry-runtime/src/object/class_value.rs | 190 ++++++++++++++++++ .../perry-runtime/src/object/descriptors.rs | 8 +- .../object/field_get_set/get_field_by_name.rs | 25 ++- .../src/object/field_set_by_name.rs | 2 + test-files/test_gap_class_name_length_own.ts | 91 +++++++++ 12 files changed, 432 insertions(+), 62 deletions(-) create mode 100644 changelog.d/11414-class-name-length-own-data.md create mode 100644 test-files/test_gap_class_name_length_own.ts diff --git a/changelog.d/11414-class-name-length-own-data.md b/changelog.d/11414-class-name-length-own-data.md new file mode 100644 index 0000000000..8770fd2f52 --- /dev/null +++ b/changelog.d/11414-class-name-length-own-data.md @@ -0,0 +1,9 @@ +### Fixed + +A class constructor's `name` and `length` are now own data properties of its +function object, as in Node: `delete C.name` makes `C.name` read the value +inherited from its prototype (`""` from `Function.prototype`, or the parent +class's name for a subclass) instead of `undefined`, `delete C.length` reads +`0`, and `Object.getOwnPropertyNames(C)` no longer lists a deleted `name` or +`length`. Reading `C.name` or `obj.constructor.name` no longer builds a new +string on every read (about 5x fewer instructions for `C.name`). diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index c51ae7f992..279fb64e26 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -95,12 +95,12 @@ pub(crate) use state::{ class_prototype_method_value_cache_root_store, class_prototype_object_addr_index_contains, class_prototype_object_addr_index_rekey, class_prototype_object_root_store, class_ref_dynamic_prop_root_store, class_register_declared_static_global_slot, - class_static_alias_sync, class_static_defined_attrs, class_static_prototype, - class_static_prototype_is_nulled, class_static_prototype_root_clear, + class_static_alias_sync, class_static_clear_defined_attrs, class_static_defined_attrs, + class_static_prototype, class_static_prototype_is_nulled, class_static_prototype_root_clear, class_static_prototype_root_store, class_static_set_defined_attrs, class_unmark_key_deleted, decl_prototype_identity_id, global_object_prototype_bits, is_bound_native_constructor_closure_value, is_non_constructable_builtin_function_value, - parent_closure_in_chain, throw_non_constructable_builtin_function, + parent_closure_in_chain, throw_non_constructable_builtin_function, CLASS_OBJECT_EVER, }; pub use state::{ AccessorDecl, ClassVTable, VTableMethodEntry, CLASS_DECL_PROTOTYPE_OBJECTS, diff --git a/crates/perry-runtime/src/object/class_registry/class_meta.rs b/crates/perry-runtime/src/object/class_registry/class_meta.rs index fd1f457868..960939aade 100644 --- a/crates/perry-runtime/src/object/class_registry/class_meta.rs +++ b/crates/perry-runtime/src/object/class_registry/class_meta.rs @@ -52,11 +52,14 @@ pub unsafe extern "C" fn js_register_class_name(class_id: u32, name_ptr: *const Ok(s) => s.to_string(), Err(_) => return, }; - let mut guard = CLASS_NAMES.write().unwrap(); - if guard.is_none() { - *guard = Some(new_ptr_hash_map()); + { + let mut guard = CLASS_NAMES.write().unwrap(); + if guard.is_none() { + *guard = Some(new_ptr_hash_map()); + } + guard.as_mut().unwrap().insert(class_id, name); } - guard.as_mut().unwrap().insert(class_id, name); + crate::object::class_value::note_intrinsic_registration(class_id, "name"); } /// Look up the user-visible name of a registered class. Returns `None` @@ -214,11 +217,14 @@ pub extern "C" fn js_register_class_length(class_id: u32, length: u32) { if class_id == 0 { return; } - let mut guard = CLASS_LENGTHS.write().unwrap(); - if guard.is_none() { - *guard = Some(new_ptr_hash_map()); + { + let mut guard = CLASS_LENGTHS.write().unwrap(); + if guard.is_none() { + *guard = Some(new_ptr_hash_map()); + } + guard.as_mut().unwrap().insert(class_id, length); } - guard.as_mut().unwrap().insert(class_id, length); + crate::object::class_value::note_intrinsic_registration(class_id, "length"); } pub fn class_length_for_id(class_id: u32) -> Option { 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 4b50d77765..47e1fc3b30 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -610,16 +610,22 @@ pub unsafe extern "C" fn js_register_class_static_method( Ok(s) => s.to_string(), Err(_) => return, }; - let mut guard = CLASS_STATIC_METHODS.write().unwrap(); - if guard.is_none() { - *guard = Some(crate::fast_hash::new_ptr_hash_map()); + { + let mut guard = CLASS_STATIC_METHODS.write().unwrap(); + if guard.is_none() { + *guard = Some(crate::fast_hash::new_ptr_hash_map()); + } + guard + .as_mut() + .unwrap() + .entry(class_id as u32) + .or_default() + .insert( + name.clone(), + (func_ptr as usize, param_count as u32, has_rest != 0), + ); } - guard - .as_mut() - .unwrap() - .entry(class_id as u32) - .or_default() - .insert(name, (func_ptr as usize, param_count as u32, has_rest != 0)); + crate::object::class_value::note_intrinsic_registration(class_id as u32, &name); } fn property_key_string(key: f64) -> Option { @@ -746,16 +752,17 @@ pub unsafe extern "C" fn js_register_class_computed_method( throw_object_type_error(b"Classes may not have a static property named 'prototype'"); } if is_static != 0 { - let mut guard = CLASS_STATIC_METHODS.write().unwrap(); - if guard.is_none() { - *guard = Some(crate::fast_hash::new_ptr_hash_map()); + { + let mut guard = CLASS_STATIC_METHODS.write().unwrap(); + if guard.is_none() { + *guard = Some(crate::fast_hash::new_ptr_hash_map()); + } + guard.as_mut().unwrap().entry(class_id).or_default().insert( + name.clone(), + (func_ptr as usize, param_count as u32, has_rest != 0), + ); } - guard - .as_mut() - .unwrap() - .entry(class_id) - .or_default() - .insert(name, (func_ptr as usize, param_count as u32, has_rest != 0)); + crate::object::class_value::note_intrinsic_registration(class_id, &name); } else { let mut registry = CLASS_VTABLE_REGISTRY.write().unwrap(); if registry.is_none() { @@ -850,23 +857,26 @@ pub unsafe extern "C" fn js_register_class_computed_accessor( drop(registry); super::decl_accessors::note_instance_accessor_registered(class_id, &name); } else { - let mut guard = CLASS_STATIC_ACCESSORS.write().unwrap(); - if guard.is_none() { - *guard = Some(crate::fast_hash::new_ptr_hash_map()); - } - let entry = guard - .as_mut() - .unwrap() - .entry(class_id) - .or_default() - .entry(name) - .or_insert((0, 0)); - if getter_ptr != 0 { - entry.0 = getter_ptr as usize; - } - if setter_ptr != 0 { - entry.1 = setter_ptr as usize; + { + let mut guard = CLASS_STATIC_ACCESSORS.write().unwrap(); + if guard.is_none() { + *guard = Some(crate::fast_hash::new_ptr_hash_map()); + } + let entry = guard + .as_mut() + .unwrap() + .entry(class_id) + .or_default() + .entry(name.clone()) + .or_insert((0, 0)); + if getter_ptr != 0 { + entry.0 = getter_ptr as usize; + } + if setter_ptr != 0 { + entry.1 = setter_ptr as usize; + } } + crate::object::class_value::note_intrinsic_registration(class_id, &name); } } VTABLE_GEN.fetch_add(1, Ordering::Release); diff --git a/crates/perry-runtime/src/object/class_registry/prototype_methods.rs b/crates/perry-runtime/src/object/class_registry/prototype_methods.rs index 8667bb0c8e..3e62dd6f8c 100644 --- a/crates/perry-runtime/src/object/class_registry/prototype_methods.rs +++ b/crates/perry-runtime/src/object/class_registry/prototype_methods.rs @@ -47,6 +47,10 @@ pub unsafe extern "C" fn js_class_register_static_field( }; class_register_declared_static_global_slot(class_id, name, global_slot); class_dynamic_prop_root_store(class_id, name, value); + // DefineField: a static field is an ordinary writable, enumerable, + // configurable own property — also when it replaces the class's + // intrinsic `name` / `length`. + crate::object::class_value::note_static_field_defined(class_id, name); } /// Read a computed instance-field key resolved at ClassDefinitionEvaluation. diff --git a/crates/perry-runtime/src/object/class_registry/registration.rs b/crates/perry-runtime/src/object/class_registry/registration.rs index 41685eed3a..a835e38fba 100644 --- a/crates/perry-runtime/src/object/class_registry/registration.rs +++ b/crates/perry-runtime/src/object/class_registry/registration.rs @@ -499,21 +499,24 @@ unsafe fn register_class_static_accessor_half( Err(_) => return, } }; - let mut guard = CLASS_STATIC_ACCESSORS.write().unwrap(); - if guard.is_none() { - *guard = Some(crate::fast_hash::new_ptr_hash_map()); - } - let entry = guard - .as_mut() - .unwrap() - .entry(class_id as u32) - .or_default() - .entry(name) - .or_insert((0, 0)); - if is_getter { - entry.0 = func_ptr as usize; - } else { - entry.1 = func_ptr as usize; + { + let mut guard = CLASS_STATIC_ACCESSORS.write().unwrap(); + if guard.is_none() { + *guard = Some(crate::fast_hash::new_ptr_hash_map()); + } + let entry = guard + .as_mut() + .unwrap() + .entry(class_id as u32) + .or_default() + .entry(name.clone()) + .or_insert((0, 0)); + if is_getter { + entry.0 = func_ptr as usize; + } else { + entry.1 = func_ptr as usize; + } } VTABLE_GEN.fetch_add(1, Ordering::Release); + crate::object::class_value::note_intrinsic_registration(class_id as u32, &name); } diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index abe062e894..f55344c3a5 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -228,6 +228,23 @@ pub(crate) fn class_static_set_defined_attrs( class_static_alias_sync(class_id, name); } +/// Forget the recorded attributes of static `name` (it becomes an ordinary +/// writable, enumerable, configurable data property again) and re-sync its +/// compiled alias. A static FIELD definition does this: DefineField creates +/// the property with CreateDataPropertyOrThrow, replacing e.g. the class's +/// own intrinsic `name`. +pub(crate) fn class_static_clear_defined_attrs(class_id: u32, name: &str) { + let removed = crate::object::CLASS_STATIC_DEFINED_ATTRS.with(|m| { + m.borrow_mut() + .get_mut(&class_id) + .and_then(|k| k.remove(name)) + .is_some() + }); + if removed { + class_static_alias_sync(class_id, name); + } +} + /// `(writable, enumerable)` if this static key was installed by /// `Object.defineProperty`; `None` for a declared `static x = …` field. pub(crate) fn class_static_defined_attrs(class_id: u32, name: &str) -> Option<(bool, bool, bool)> { @@ -579,12 +596,21 @@ crate::perry_thread_local! { pub static CLASS_OBJECT_VALUES: RwLock>> = RwLock::new(None); } +/// Monotone: has any per-evaluation class object (`ClassExprFresh`) been +/// recorded in this process? While clear, `class_object_value_for_cid` is +/// `None` for every class, so a read of a class function object's own data +/// property answers from its own-property object without consulting the +/// per-evaluation table first. +pub(crate) static CLASS_OBJECT_EVER: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + /// Store the marked class object for its template class id (see /// `CLASS_OBJECT_VALUES`). pub(crate) fn class_object_value_root_store(class_id: u32, obj_ptr: *mut ObjectHeader) { if class_id == 0 || obj_ptr.is_null() { return; } + CLASS_OBJECT_EVER.store(true, std::sync::atomic::Ordering::Relaxed); let bits = crate::value::js_nanbox_pointer(obj_ptr as i64).to_bits(); CLASS_OBJECT_VALUES.with(|table| { let mut guard = table.write().unwrap(); diff --git a/crates/perry-runtime/src/object/class_value.rs b/crates/perry-runtime/src/object/class_value.rs index f1b9a1c33d..9f5edf39c6 100644 --- a/crates/perry-runtime/src/object/class_value.rs +++ b/crates/perry-runtime/src/object/class_value.rs @@ -252,9 +252,108 @@ fn class_value_mint(class_id: u32) -> *mut ClosureHeader { // SAFETY: the slot is this agent's table entry for `class_id`. unsafe { *class_value_slot(class_id) = ptr }; crate::gc::runtime_write_barrier_root_heap_word(ptr as u64); + // Still inside the no-collect scope: the own-property object and its + // keys allocate. + for key in INTRINSIC_OWN_DATA_KEYS { + install_intrinsic_own_data(class_id, key); + } ptr } +/// A class constructor's `length` and `name`, in creation order +/// (ClassDefinitionEvaluation: SetFunctionLength, then SetFunctionName). +const INTRINSIC_OWN_DATA_KEYS: [&str; 2] = ["length", "name"]; + +/// The attributes of a function's own `length` / `name`. +const INTRINSIC_ATTRS: (bool, bool, bool) = (false, false, true); + +/// The value of intrinsic own data property `key` of class `class_id`, if +/// the class registered one. +fn intrinsic_own_data_value(class_id: u32, key: &str) -> Option { + match key { + "length" => super::class_registry::class_length_for_id(class_id).map(f64::from), + "name" => super::class_registry::class_name_for_id(class_id).map(|name| { + let s = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + f64::from_bits(crate::value::JSValue::string_ptr(s).bits()) + }), + _ => None, + } +} + +/// Does a static method or accessor of class `class_id` own `key`? Then it, +/// not the intrinsic data property, is the class's own `key`. +fn static_member_owns(class_id: u32, key: &str) -> bool { + super::class_registry::class_has_own_static_method(class_id, key) + || super::class_registry::class_own_static_accessor_ptrs(class_id, key).is_some() +} + +/// Is own `key` of class `class_id` still the intrinsic data property (not +/// replaced by a static field, a `defineProperty`, or deleted)? +fn holds_intrinsic(class_id: u32, key: &str) -> bool { + class_static_get(class_id, key).is_some() + && super::class_registry::class_static_defined_attrs(class_id, key) == Some(INTRINSIC_ATTRS) +} + +/// ClassDefinitionEvaluation's SetFunctionLength / SetFunctionName: `length` +/// and `name` are own DATA properties of the class's function object, +/// `{ writable: false, enumerable: false, configurable: true }`, kept in its +/// own-property object with every other own data property — so `C.name` and +/// `x.constructor.name` are one lookup in that object's shape. A static +/// method or accessor of the same name is the class's own property instead, +/// and a static field or `defineProperty` of that name replaces it. +fn install_intrinsic_own_data(class_id: u32, key: &str) { + if static_member_owns(class_id, key) { + return; + } + let Some(value) = intrinsic_own_data_value(class_id, key) else { + return; + }; + class_static_set(class_id, key, value); + let (writable, enumerable, configurable) = INTRINSIC_ATTRS; + super::class_registry::class_static_set_defined_attrs( + class_id, + key, + writable, + enumerable, + configurable, + ); +} + +/// A static field `key` was defined on class `class_id`: if it replaced the +/// intrinsic `name` / `length`, the property keeps the field's (ordinary) +/// attributes, not the intrinsic's. +pub(crate) fn note_static_field_defined(class_id: u32, key: &str) { + if INTRINSIC_OWN_DATA_KEYS.contains(&key) + && super::class_registry::class_static_defined_attrs(class_id, key) == Some(INTRINSIC_ATTRS) + { + super::class_registry::class_static_clear_defined_attrs(class_id, key); + } +} + +/// The registry changed what class `class_id`'s intrinsic `key` is (its +/// name or length registered, or a static method / accessor of that name +/// registered) after this agent minted its function object: bring the own +/// property in line. A key the program already redefined or deleted is left +/// alone. +pub(crate) fn note_intrinsic_registration(class_id: u32, key: &str) { + if !INTRINSIC_OWN_DATA_KEYS.contains(&key) || class_value_cached(class_id).is_none() { + return; + } + let _no_collect = crate::gc::GcSuppressScope::new(); + if holds_intrinsic(class_id, key) { + if static_member_owns(class_id, key) { + class_static_remove(class_id, key); + super::class_registry::class_static_clear_defined_attrs(class_id, key); + } else if let Some(value) = intrinsic_own_data_value(class_id, key) { + class_static_set(class_id, key, value); + } + } else if class_static_get(class_id, key).is_none() + && !super::class_registry::class_is_key_deleted(class_id, key) + { + install_intrinsic_own_data(class_id, key); + } +} + /// The class function object for `class_id` on this agent (minted on first /// use). `class_id` must be a registered class. #[inline] @@ -359,6 +458,44 @@ pub unsafe extern "C" fn js_class_static_field_put( crate::object::js_object_set_field_by_name(receiver, key, value); } +/// [[Get]] of `key` on class `class_id`'s [[Prototype]], `receiver` as the +/// receiver: the continuation of a read of a key the class does not own +/// (e.g. its own `name` was deleted — `Sub.name` then reads `Base.name`, a +/// base class reads `Function.prototype.name`). The [[Prototype]] is the +/// recorded one (`Object.setPrototypeOf(C, p)`), else the parent class's +/// function object, else the parent function (`extends `), else +/// %Function.prototype%. +pub(crate) fn class_prototype_get( + class_id: u32, + key: *const crate::StringHeader, + receiver: f64, +) -> crate::value::JSValue { + use crate::value::JSValue; + if super::class_registry::class_static_prototype_is_nulled(class_id) { + return JSValue::undefined(); + } + let proto = super::class_registry::class_static_prototype(class_id) as usize; + let proto = if proto != 0 { + proto + } else if let Some(parent) = super::get_parent_class_id(class_id) + .filter(|&p| p != 0 && p != class_id && super::is_class_id_registered(p)) + { + class_value_ptr(parent) as usize + } else if let Some(parent) = super::class_registry::class_parent_closure(class_id) { + parent + } else { + crate::closure::shape::FUNCTION_PROTOTYPE_PTR.load(std::sync::atomic::Ordering::Acquire) + as usize + }; + if proto == 0 { + return JSValue::undefined(); + } + let prev = super::field_get_set::accessor_receiver_override_begin(receiver); + let value = super::js_object_get_field_by_name(proto as *const super::ObjectHeader, key); + super::field_get_set::accessor_receiver_override_end(prev); + value +} + // --------------------------------------------------------------------------- // Statics: the class function object's OWN properties. // --------------------------------------------------------------------------- @@ -519,6 +656,59 @@ mod tests { ); } + /// A class constructor's `length` and `name` are own data properties of + /// its function object (in its own-property object, intrinsic + /// attributes); a static method of that name owns the key instead. + #[test] + fn name_and_length_are_own_data_of_the_function_object() { + let cid = 0x6D01; + register(cid); + unsafe { crate::object::js_register_class_name(cid, b"Zed".as_ptr(), 3) }; + crate::object::js_register_class_length(cid, 2); + let ptr = class_value_ptr(cid) as usize; + assert_eq!( + unsafe { crate::closure::props::bag_get(ptr, b"length") }, + Some(2.0), + "own length" + ); + let name = unsafe { crate::closure::props::bag_get(ptr, b"name") }.expect("own name"); + let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + // SAFETY: a live string value just read from the object. + let bytes = unsafe { + crate::string::js_string_key_bytes( + crate::value::JSValue::from_bits(name.to_bits()), + &mut scratch, + ) + } + .expect("a string"); + assert_eq!(bytes, b"Zed"); + for key in ["length", "name"] { + assert_eq!( + crate::object::class_registry::class_static_defined_attrs(cid, key), + Some(INTRINSIC_ATTRS), + "{key}: non-writable, non-enumerable, configurable" + ); + } + extern "C" fn static_name() -> f64 { + 0.0 + } + unsafe { + crate::object::class_registry::js_register_class_static_method( + cid as i64, + b"name".as_ptr(), + 4, + static_name as usize as i64, + 0, + 0, + ) + }; + assert_eq!( + unsafe { crate::closure::props::bag_get(ptr, b"name") }, + None, + "a static method named `name` is the class's own `name`" + ); + } + /// Only the function object's own code pointer names a class. #[test] fn ordinary_closures_and_numbers_are_not_class_values() { diff --git a/crates/perry-runtime/src/object/descriptors.rs b/crates/perry-runtime/src/object/descriptors.rs index 1c7a0409ed..d408b2f536 100644 --- a/crates/perry-runtime/src/object/descriptors.rs +++ b/crates/perry-runtime/src/object/descriptors.rs @@ -1190,7 +1190,13 @@ fn js_object_get_own_property_names_shape(obj_value: f64) -> f64 { push_unique_name(&mut names, name); } } - names.retain(|n| !super::field_get_set::is_internal_runtime_key(n)); + names.retain(|n| { + !super::field_get_set::is_internal_runtime_key(n) + // A deleted `length` / `name` is no longer own. + && !(!is_prototype_ref + && matches!(n.as_str(), "length" | "name") + && super::class_registry::class_is_key_deleted(class_id, n)) + }); sort_property_names_ecma(&mut names); let result = crate::array::js_array_alloc(names.len() as u32); for name in names { diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index 87f9ee9950..0c97e878b9 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -97,6 +97,27 @@ pub(crate) fn class_value_get_field( bits: u64, class_id: u32, ) -> JSValue { + // A class function object's own data property (a static field, a runtime + // `C.x = v`): its bag answers first — own data wins [[Get]] — unless a + // per-evaluation class object exists for some class (the redirect below + // then decides). + if bits >> 48 != 0x7FFE + && !super::super::class_registry::CLASS_OBJECT_EVER + .load(std::sync::atomic::Ordering::Relaxed) + { + let ptr = (bits & crate::value::POINTER_MASK) as usize; + // SAFETY: a class function object (the caller decoded `class_id` from + // it); `key` is a live string header. + unsafe { + let bytes = std::slice::from_raw_parts( + (key as *const u8).add(std::mem::size_of::()), + (*key).byte_len as usize, + ); + if let Some(v) = crate::closure::props::bag_get(ptr, bytes) { + return JSValue::from_bits(v.to_bits()); + } + } + } let class_value = crate::object::class_value::boxed_class_word(bits); let is_prototype_ref = super::super::class_prototype_ref_id(class_value).is_some(); unsafe { @@ -219,7 +240,9 @@ pub(crate) fn class_value_get_field( } if !name.is_empty() { if super::super::class_registry::class_is_key_deleted(class_id, name) { - return JSValue::undefined(); + // Not an own property any more: the read continues on the + // class's [[Prototype]]. + return crate::object::class_value::class_prototype_get(class_id, key, class_value); } let result = crate::object::class_value::class_static_get(class_id, name); if let Some(v) = result { diff --git a/crates/perry-runtime/src/object/field_set_by_name.rs b/crates/perry-runtime/src/object/field_set_by_name.rs index d71938261d..2c7d3d35d8 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -444,6 +444,8 @@ pub extern "C" fn js_object_set_field_by_name( && !super::class_registry::class_is_key_deleted(class_id, &name) && super::class_registry::lookup_static_method_in_chain(class_id, &name) .is_none() + && super::class_registry::class_static_defined_attrs(class_id, &name) + .is_none_or(|(writable, _, _)| !writable) { return; } diff --git a/test-files/test_gap_class_name_length_own.ts b/test-files/test_gap_class_name_length_own.ts new file mode 100644 index 0000000000..d1bfde5d59 --- /dev/null +++ b/test-files/test_gap_class_name_length_own.ts @@ -0,0 +1,91 @@ +// A class constructor's `length` and `name` are own data properties of its +// function object: { writable: false, enumerable: false, configurable: true }, +// created before any static. Static fields, methods, accessors and +// defineProperty of the same name replace them; delete removes them. +function show(label: string, v: any): void { + console.log(label, JSON.stringify(v)); +} +function desc(o: any, k: string): string { + const d = Object.getOwnPropertyDescriptor(o, k); + if (!d) return "none"; + return `${typeof d.value === "function" ? "fn" : JSON.stringify(d.value)} w=${d.writable} e=${d.enumerable} c=${d.configurable}`; +} + +class A { + static s = 1; + constructor(a: number, b: number) {} +} +show("A.name", A.name); +show("A.length", A.length); +show("names", Object.getOwnPropertyNames(A)); +show("keys", Object.keys(A)); +show("entries", Object.entries(A)); +show("spread", { ...(A as any) }); +show("assign", Object.assign({}, A)); +console.log("desc name", desc(A, "name")); +console.log("desc length", desc(A, "length")); +console.log("own", A.hasOwnProperty("name"), A.hasOwnProperty("length"), "name" in A); +const forin: string[] = []; +for (const k in A) forin.push(k); +show("forin", forin); + +const anyA: any = A; +show("dyn name", anyA.name); +show("dyn length", anyA["length"]); +show("ctor name", new A(1, 2).constructor.name); + +class B extends A {} +show("B.name", B.name); +show("B.length", B.length); +show("B names", Object.getOwnPropertyNames(B)); + +class F { + static name = "Field"; +} +show("F.name", F.name); +console.log("F desc", desc(F, "name")); +show("F keys", Object.keys(F)); +show("F names", Object.getOwnPropertyNames(F)); + +class M { + static name() { + return "method"; + } +} +show("M.name()", M.name()); +console.log("M desc", desc(M, "name")); + +class G { + static get name() { + return "getter"; + } +} +show("G.name", G.name); + +class D {} +Object.defineProperty(D, "name", { value: "Defined" }); +show("D.name", D.name); +console.log("D desc", desc(D, "name")); +show("D keys", Object.keys(D)); + +class E {} +show("delete", delete (E as any).name); +show("E.name after delete", E.name); +show("E own", E.hasOwnProperty("name")); +show("E names", Object.getOwnPropertyNames(E)); + +class L { + constructor(a: number, b = 2, ...rest: number[]) {} +} +show("L.length", L.length); +delete (L as any).length; +show("L.length after delete", L.length); + + +const Named = class {}; +show("named expr", Named.name); + +class P { + static x = 5; +} +console.log(P); From 3a24e309b09db46548a4e93e585ceab397d3fb47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 21:21:59 +0000 Subject: [PATCH 18/29] chore: gate inventories after the port onto the function-own-props base - gc_runtime_root_holders: CLASS_STATIC_PROTOTYPES / CLASS_STATIC_PROTOTYPE_NULLED entries deleted (the tables went with the recorded-[[Prototype]] commit); PASS1_MARKED's gc/mod.rs pin re-audited and moved: the only change is the one class-value reg_scanner! registration (noted in its why). - thread_exit_address_globals: CLASS_STATIC_SYMBOLS / CLASS_STATIC_SYMBOL_ORDER entries deleted (class static symbols are owner-keyed own symbol properties; the owner-keyed pass releases them). - registry_lifetime allowlist: CLASS_STATIC_SYMBOL_ORDER deleted (gone) and CLASS_STATIC_DEFINED_ATTRS deleted (it now has a removal path, class_static_clear_defined_attrs). - The class read's own-property probe reads the key through string_data (string payload-access ratchet); the intrinsic-name unit test casts its fn pointer through *const () (-D warnings on all targets). --- crates/perry-runtime/src/object/class_value.rs | 2 +- .../object/field_get_set/get_field_by_name.rs | 2 +- scripts/gc_runtime_root_holders.json | 15 +-------------- scripts/registry_lifetime_allowlist.json | 12 ------------ scripts/thread_exit_address_globals.json | 17 ----------------- 5 files changed, 3 insertions(+), 45 deletions(-) diff --git a/crates/perry-runtime/src/object/class_value.rs b/crates/perry-runtime/src/object/class_value.rs index 9f5edf39c6..ea584c15ec 100644 --- a/crates/perry-runtime/src/object/class_value.rs +++ b/crates/perry-runtime/src/object/class_value.rs @@ -697,7 +697,7 @@ mod tests { cid as i64, b"name".as_ptr(), 4, - static_name as usize as i64, + static_name as *const () as usize as i64, 0, 0, ) diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index 0c97e878b9..3e3677b1e3 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -110,7 +110,7 @@ pub(crate) fn class_value_get_field( // it); `key` is a live string header. unsafe { let bytes = std::slice::from_raw_parts( - (key as *const u8).add(std::mem::size_of::()), + crate::string::string_data(key), (*key).byte_len as usize, ); if let Some(v) = crate::closure::props::bag_get(ptr, bytes) { diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index c019d62fe5..31f32f8852 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -403,7 +403,7 @@ "sources": { "crates/perry-runtime/src/gc/census.rs": "25601f25ac70aa998f8cb5c1939d11e7c43a96235d4edf39a78e261b68709471", "crates/perry-runtime/src/gc/cycle.rs": "4744196ba5e9c5ac40912154cf5b45b4a618d81ddc776ab1095fbc585f27c878", - "crates/perry-runtime/src/gc/mod.rs": "bb38a949495846fd30598064bb04d8e8d384721c25a9a21d1029da1e0c5caa3c", + "crates/perry-runtime/src/gc/mod.rs": "78f68fd77965239935bf501c831a6dfd035d4af18692a487d5aeb2dee2e332b6", "crates/perry-runtime/src/gc/policy.rs": "7e2304ec822d26877df4eaf7aefb55c485c66425b8946b56a9b271a0abcc635b", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } @@ -879,19 +879,6 @@ "scanner": "object::scan_class_side_table_roots_mut and its budgeted step twin (class_registry/gc_roots.rs:138 and :256)", "why": "The class side tables are declared in state.rs and scanned from gc_roots.rs. Both twins visit it \u2014 #7239 diffed all eight budgeted (FULL, STEP) pairs and found no drift." }, - { - "file": "crates/perry-runtime/src/object/class_registry/state.rs", - "name": "CLASS_STATIC_PROTOTYPES", - "verdict": "covered_elsewhere", - "scanner": "object::class_registry::gc_roots::scan_class_side_table_roots_mut and its budgeted step twin (class_side_table_root_snapshot enumerates ClassSideTableRootSlot::StaticPrototype; scan_class_side_table_root_slot visits that slot)", - "why": "Constructor-side [[Prototype]] recorded by Object.setPrototypeOf(Ctor, obj) on a declared class. Holds a real heap ObjectHeader address as usize, so it is visited with visit_usize_slot in BOTH the full and budgeted class-side-table walks, exactly like the CLASS_DECL_PROTOTYPE_OBJECTS entries beside it, and class_static_prototype_root_store fires runtime_write_barrier_root_raw_ptr on the stored pointer." - }, - { - "file": "crates/perry-runtime/src/object/class_registry/state.rs", - "name": "CLASS_STATIC_PROTOTYPE_NULLED", - "verdict": "not_a_gc_pointer", - "why": "Set of class ids whose constructor [[Prototype]] was explicitly set to null, so Object.getPrototypeOf answers null rather than the default Function.prototype. Stores u32 class ids only \u2014 no heap address, nothing to trace or forward." - }, { "file": "crates/perry-runtime/src/object/class_registry/state.rs", "name": "CLASS_SYMBOL_MEMBER_ORDERS", diff --git a/scripts/registry_lifetime_allowlist.json b/scripts/registry_lifetime_allowlist.json index 6579dca063..ad6345431b 100644 --- a/scripts/registry_lifetime_allowlist.json +++ b/scripts/registry_lifetime_allowlist.json @@ -427,12 +427,6 @@ "verdict": "bounded_by_program", "why": "(class_id, method name) -> bound method closure cache, filled only for vtable-registered methods" }, - { - "file": "crates/perry-runtime/src/object/mod.rs", - "name": "CLASS_STATIC_DEFINED_ATTRS", - "verdict": "bounded_by_program", - "why": "(class_id, static key) -> attrs from Object.defineProperty(C,k): per distinct key, not per op" - }, { "file": "crates/perry-runtime/src/object/native_module.rs", "name": "NATIVE_CALLABLE_EXPORTS", @@ -500,12 +494,6 @@ "verdict": "bounded_by_constant", "why": "HashSet of &'static FFI stub symbol names for first-call warnings: the fixed set of stubs in the runtime" }, - { - "file": "crates/perry-runtime/src/symbol.rs", - "name": "CLASS_STATIC_SYMBOL_ORDER", - "verdict": "bounded_by_program", - "why": "Keyed by class_id (one per declaration site) -> symbol ids of its static [sym] members" - }, { "file": "crates/perry-runtime/src/symbol.rs", "name": "REGISTERED_SYMBOL_DESCRIPTIONS", diff --git a/scripts/thread_exit_address_globals.json b/scripts/thread_exit_address_globals.json index db97d8f5b0..46df831f72 100644 --- a/scripts/thread_exit_address_globals.json +++ b/scripts/thread_exit_address_globals.json @@ -3554,23 +3554,6 @@ "verdict": "no_heap_address", "why": "Monotonic u64 id counter handed out by next_id() to every new SymbolHeader." }, - { - "file": "crates/perry-runtime/src/symbol.rs", - "names": [ - "CLASS_STATIC_SYMBOLS" - ], - "verdict": "thread_exit_invalidated", - "why": "Class ids are process-global but members hold symbol addresses and JS values from the writing thread; members with a freed symbol or value are dropped at thread exit.", - "hook": "release_symbol_tables_in_freed_ranges" - }, - { - "file": "crates/perry-runtime/src/symbol.rs", - "names": [ - "CLASS_STATIC_SYMBOL_ORDER" - ], - "verdict": "no_heap_address", - "why": "class_id -> Vec of SymbolHeader::id (monotonic u64 ids, stable across moves) recording creation order (symbol.rs:1162-1172); holds no addresses." - }, { "file": "crates/perry-runtime/src/intl/segments_view.rs", "names": [ From 83dbfb7a6bfdd628307c8fbf831fbc9784f79dff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 07:06:40 +0200 Subject: [PATCH 19/29] changelog: name the fragments after PR #11609 --- ...ame-length-own-data.md => 11609-class-name-length-own-data.md} | 0 ...tion-objects.md => 11609-class-values-are-function-objects.md} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{11414-class-name-length-own-data.md => 11609-class-name-length-own-data.md} (100%) rename changelog.d/{11414-class-values-are-function-objects.md => 11609-class-values-are-function-objects.md} (100%) diff --git a/changelog.d/11414-class-name-length-own-data.md b/changelog.d/11609-class-name-length-own-data.md similarity index 100% rename from changelog.d/11414-class-name-length-own-data.md rename to changelog.d/11609-class-name-length-own-data.md diff --git a/changelog.d/11414-class-values-are-function-objects.md b/changelog.d/11609-class-values-are-function-objects.md similarity index 100% rename from changelog.d/11414-class-values-are-function-objects.md rename to changelog.d/11609-class-values-are-function-objects.md From ed76b138963d1d518efa4b1e017c232fb744e4e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 08:41:46 +0200 Subject: [PATCH 20/29] wasm32: regenerate runtime_abi.tsv for the class-as-function-object signatures --- crates/perry-codegen/src/wasm32/runtime_abi.tsv | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/perry-codegen/src/wasm32/runtime_abi.tsv b/crates/perry-codegen/src/wasm32/runtime_abi.tsv index 9ae4f23380..e7266e8ec6 100644 --- a/crates/perry-codegen/src/wasm32/runtime_abi.tsv +++ b/crates/perry-codegen/src/wasm32/runtime_abi.tsv @@ -581,6 +581,7 @@ js_class_capture_value f64 i32u,i32u js_class_capture_value_for_receiver f64 f64,i32u,i32u js_class_capture_value_or f64 i32u,i32u,f64 js_class_computed_field_key f64 f64,i32u,ptr,usize +js_class_constructor_called f64 ptr js_class_env_current f64 f64,i32u js_class_env_evaluate void i32u,f64,f64 js_class_env_get f64 f64,i32u,i32u @@ -608,7 +609,10 @@ js_class_prototype_method_value f64 f64,f64 js_class_register_capture_values void i32u,ptr,usize js_class_register_static_field void i32u,ptr,usize,f64,ptr js_class_register_static_symbol void i32u,f64,f64 +js_class_static_field_get f64 i32s,ptr,i64 +js_class_static_field_put void i32s,ptr,i64,f64 js_class_static_method_call f64 f64,ptr,usize,ptr,usize +js_class_value f64 i32s js_clear_exception void js_clear_immediate_value void f64 js_clear_interval_value void f64 @@ -3519,6 +3523,7 @@ js_state_set void f64,f64 js_static_this_arm_classref void i32u js_static_this_arm_value void f64 js_static_this_resolve f64 f64 +js_static_this_resolve_class f64 i32s,ptr js_stdlib_has_active_handles i32s js_stdlib_init_dispatch void js_stdlib_install_bundled_nodemailer void From df94a3a443ada05b3b1f2fd7df81c4adb5e9bebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 10:57:27 +0000 Subject: [PATCH 21/29] fix(codegen): a class expression's capture refresh re-reads its capture array from a root RefreshClassExprCaptures allocated the capture array, then lowered each capture and pushed it, passing the js_array_alloc register to every js_array_push_f64. A capture can collect (an IC-miss property read, a getter), so a moving minor between the allocation and a push handed the push a from-space array. gc-root-dominance --stale-registers found it on #11609's test_gap_class_value_reflection.ts (source=alloc -> sink=js_array_push_f64), the third use against the curated budget of 2. The array now lives in a temp root (rooting::call_rooted): each push re-reads it there (Arg::Root), each intermediate push result is rooted in turn, and only the last push's result becomes a register, after the last capture. The slots are released with one stack cut on every path out. No new table. Curated corpus: --stale-registers --moving-only back to 2 (main's two InOrder.run uses). New codegen test class_expr_capture_refresh_rereads_its_capture_array_below_each_capture is red with the old lowering. --- .../src/expr/slice7_rooting_tests.rs | 52 +++++++++++++++++ .../src/expr/static_field_meta.rs | 58 +++++++++++++------ 2 files changed, 91 insertions(+), 19 deletions(-) diff --git a/crates/perry-codegen/src/expr/slice7_rooting_tests.rs b/crates/perry-codegen/src/expr/slice7_rooting_tests.rs index 002cd3c5a6..e566d0988e 100644 --- a/crates/perry-codegen/src/expr/slice7_rooting_tests.rs +++ b/crates/perry-codegen/src/expr/slice7_rooting_tests.rs @@ -556,3 +556,55 @@ fn proxy_function_apply_uses_argument_validation_bridge() { "Function.prototype.apply must validate and convert its argument list" ); } + +// --------------------------------------------------------------------------- +// expr/static_field_meta.rs — RefreshClassExprCaptures +// --------------------------------------------------------------------------- + +/// A class expression's capture refresh builds its capture array with one +/// push per capture. The array is live across every capture's lowering, and a +/// capture can collect, so each push must read the array from its root, not +/// from the `js_array_alloc` register (gc-root-dominance --stale-registers +/// flagged `source=alloc -> sink=js_array_push_f64` on #11609). +#[test] +fn class_expr_capture_refresh_rereads_its_capture_array_below_each_capture() { + let ir = compile_body( + "refresh_class_expr_captures", + vec![Stmt::Expr(Expr::RefreshClassExprCaptures { + class_value: Box::new(Expr::Undefined), + captures: vec![allocating("first"), allocating("second")], + env_class: None, + })], + ); + require_call_line(&ir, "js_array_alloc"); + assert_operand_survives_the_window( + &ir, + "js_array_push_f64", + 0, + "the capture array pushed after the first capture", + ); + let pushes: Vec = ir + .lines() + .enumerate() + .filter(|(_, l)| { + l.contains("@js_array_push_f64(") && !l.trim_start().starts_with("declare") + }) + .map(|(i, _)| i) + .collect(); + assert_eq!(pushes.len(), 2, "one push per capture:\n{ir}"); + let last = *pushes.last().unwrap(); + let line = ir.lines().nth(last).unwrap(); + let reg = line + .split("@js_array_push_f64(i64 ") + .nth(1) + .and_then(|rest| rest.split(',').next()) + .unwrap_or_else(|| panic!("unexpected push shape: {line}")) + .to_string(); + let def = require_definition_line(&ir, ®); + let alloc = last_alloc_before(&ir, last); + assert!( + def > alloc, + "the second push reads {reg} (line {def}) from above the second capture's allocation \ + (line {alloc}):\n{ir}" + ); +} diff --git a/crates/perry-codegen/src/expr/static_field_meta.rs b/crates/perry-codegen/src/expr/static_field_meta.rs index 6d0647ae34..d38c182eea 100644 --- a/crates/perry-codegen/src/expr/static_field_meta.rs +++ b/crates/perry-codegen/src/expr/static_field_meta.rs @@ -307,25 +307,45 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { env_class, } => { 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 (index, capture) in captures.iter().enumerate() { - let value = lower_expr(ctx, capture)?; - if let Some(env_class) = env_class { - super::class_env::store_class_env_slot( - ctx, - env_class, - index as u32, - &value, - capture, - ); - } - caps_arr = ctx.block().call( - I64, - "js_array_push_f64", - &[(I64, &caps_arr), (DOUBLE, &value)], - ); - } + // The capture array is live across every capture's lowering, and a + // capture can collect (a property read through an IC miss, a + // getter): it lives in a root slot, and each push re-reads it from + // there. Each push's result (the array may grow) is rooted in turn; + // only the last one becomes a register, after the last capture. + use crate::rooting::{call_rooted, call_with_roots, Arg}; + let caps_arr = if captures.is_empty() { + ctx.block().call_void("js_tdz_suppress_begin", &[]); + ctx.block().call(I64, "js_array_alloc", &[(I32, &cap_len)]) + } else { + let first = call_rooted(ctx, I64, "js_array_alloc", &[Arg::Plain(I32, &cap_len)]); + ctx.block().call_void("js_tdz_suppress_begin", &[]); + let last = captures.len() - 1; + let pushed = (|| -> Result { + let mut current = first.clone(); + for (index, capture) in captures.iter().enumerate() { + let value = lower_expr(ctx, capture)?; + if let Some(env_class) = env_class { + super::class_env::store_class_env_slot( + ctx, + env_class, + index as u32, + &value, + capture, + ); + } + let args = [Arg::Root(¤t), Arg::Plain(DOUBLE, &value)]; + if index == last { + return Ok(call_with_roots(ctx, I64, "js_array_push_f64", &args)); + } + current = call_rooted(ctx, I64, "js_array_push_f64", &args); + } + unreachable!("the last capture returns") + })(); + // A stack cut: releases every slot pushed after `first` too, on + // the error path as well. + first.release(ctx); + pushed? + }; 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 From ff0eaeb45e49aff16bfa1b37454e3077b4a209bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 16:18:23 +0200 Subject: [PATCH 22/29] gc_effects: regenerate tables for the class function object entries --- .../src/gc_effects/linux-x86_64.tsv | 25 +++++++++++-------- .../src/gc_effects/macos-aarch64.tsv | 23 ++++++++++------- .../src/gc_effects/windows-x86_64.tsv | 25 +++++++++++-------- 3 files changed, 44 insertions(+), 29 deletions(-) diff --git a/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv b/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv index b71c7ad644..626a321689 100644 --- a/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv +++ b/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv @@ -573,6 +573,7 @@ js_class_capture_value Leaf js_class_capture_value_for_receiver Reenters js_class_capture_value_or Leaf js_class_computed_field_key Reenters +js_class_constructor_called Reenters js_class_env_current Reenters js_class_env_evaluate Reenters js_class_env_get Reenters @@ -589,8 +590,8 @@ js_class_field_set_fallback Reenters js_class_field_set_ic Reenters js_class_field_set_ic_fast Leaf js_class_field_set_ic_fast_miss Reenters -js_class_lexical_binding_get Leaf -js_class_lexical_binding_set Leaf +js_class_lexical_binding_get Reenters +js_class_lexical_binding_set Reenters js_class_method_bind Reenters js_class_method_bind_by_id Reenters js_class_method_snapshot_bind Reenters @@ -598,9 +599,12 @@ js_class_object_pin_parent Reenters js_class_object_refresh_capture_values Reenters js_class_prototype_method_value Reenters js_class_register_capture_values Leaf -js_class_register_static_field Leaf +js_class_register_static_field Reenters js_class_register_static_symbol Reenters +js_class_static_field_get Reenters +js_class_static_field_put Reenters js_class_static_method_call Reenters +js_class_value Reenters js_clear_exception Leaf js_clear_immediate_value Reenters js_clear_interval_value Reenters @@ -2141,7 +2145,7 @@ js_object_get_symbol_property_ic_miss Reenters js_object_get_symbol_then_field_ic_miss Reenters js_object_group_by Reenters js_object_has_own Reenters -js_object_has_own_symbol AllocOnly +js_object_has_own_symbol Reenters js_object_has_property Reenters js_object_is Reenters js_object_is_extensible Reenters @@ -2731,19 +2735,19 @@ js_register_class_generic_origin Leaf js_register_class_getter Reenters js_register_class_has_instance Leaf js_register_class_id Leaf -js_register_class_length Leaf +js_register_class_length Reenters js_register_class_method Leaf js_register_class_method_bind_length Leaf -js_register_class_name Leaf +js_register_class_name Reenters js_register_class_parent Leaf js_register_class_parent_dynamic Reenters js_register_class_setter Reenters js_register_class_source Leaf js_register_class_source_static Leaf -js_register_class_static_getter Leaf -js_register_class_static_method Leaf +js_register_class_static_getter Reenters +js_register_class_static_method Reenters js_register_class_static_method_bind_length Leaf -js_register_class_static_setter Leaf +js_register_class_static_setter Reenters js_register_class_string_member_order Leaf js_register_class_to_string_tag Leaf js_register_closure_arity Leaf @@ -3032,9 +3036,10 @@ js_sqlite_transaction Reenters js_state_get Reenters js_state_init Reenters js_state_set Reenters -js_static_this_arm_classref Leaf +js_static_this_arm_classref Reenters js_static_this_arm_value Leaf js_static_this_resolve Leaf +js_static_this_resolve_class Reenters js_stdlib_has_active_handles Reenters js_stdlib_init_dispatch Reenters js_stdlib_install_bundled_nodemailer Leaf diff --git a/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv b/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv index 405bf6af51..416ac68806 100644 --- a/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv +++ b/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv @@ -573,6 +573,7 @@ js_class_capture_value Leaf js_class_capture_value_for_receiver Reenters js_class_capture_value_or Leaf js_class_computed_field_key Reenters +js_class_constructor_called ThrowOnly js_class_env_current Reenters js_class_env_evaluate Reenters js_class_env_get Reenters @@ -589,8 +590,8 @@ js_class_field_set_fallback Reenters js_class_field_set_ic Reenters js_class_field_set_ic_fast Leaf js_class_field_set_ic_fast_miss Reenters -js_class_lexical_binding_get Leaf -js_class_lexical_binding_set Leaf +js_class_lexical_binding_get Reenters +js_class_lexical_binding_set Reenters js_class_method_bind Reenters js_class_method_bind_by_id Reenters js_class_method_snapshot_bind Reenters @@ -598,9 +599,12 @@ js_class_object_pin_parent Reenters js_class_object_refresh_capture_values Reenters js_class_prototype_method_value Reenters js_class_register_capture_values Leaf -js_class_register_static_field Leaf +js_class_register_static_field Reenters js_class_register_static_symbol Reenters +js_class_static_field_get Reenters +js_class_static_field_put Reenters js_class_static_method_call Reenters +js_class_value Reenters js_clear_exception Leaf js_clear_immediate_value Reenters js_clear_interval_value Reenters @@ -2141,7 +2145,7 @@ js_object_get_symbol_property_ic_miss Reenters js_object_get_symbol_then_field_ic_miss Reenters js_object_group_by Reenters js_object_has_own Reenters -js_object_has_own_symbol AllocOnly +js_object_has_own_symbol Reenters js_object_has_property Reenters js_object_is AllocOnly js_object_is_extensible Reenters @@ -2734,16 +2738,16 @@ js_register_class_id Reenters js_register_class_length Reenters js_register_class_method Leaf js_register_class_method_bind_length Reenters -js_register_class_name Leaf +js_register_class_name Reenters js_register_class_parent Reenters js_register_class_parent_dynamic Reenters js_register_class_setter Reenters js_register_class_source Leaf js_register_class_source_static Leaf -js_register_class_static_getter Leaf -js_register_class_static_method Leaf +js_register_class_static_getter Reenters +js_register_class_static_method Reenters js_register_class_static_method_bind_length Reenters -js_register_class_static_setter Leaf +js_register_class_static_setter Reenters js_register_class_string_member_order Leaf js_register_class_to_string_tag Reenters js_register_closure_arity Leaf @@ -3032,9 +3036,10 @@ js_sqlite_transaction Reenters js_state_get Reenters js_state_init Reenters js_state_set Reenters -js_static_this_arm_classref Leaf +js_static_this_arm_classref Reenters js_static_this_arm_value Leaf js_static_this_resolve Leaf +js_static_this_resolve_class Reenters js_stdlib_has_active_handles Reenters js_stdlib_init_dispatch Reenters js_stdlib_install_bundled_nodemailer Leaf diff --git a/crates/perry-codegen/src/gc_effects/windows-x86_64.tsv b/crates/perry-codegen/src/gc_effects/windows-x86_64.tsv index 8334ff53d5..784bc9a415 100644 --- a/crates/perry-codegen/src/gc_effects/windows-x86_64.tsv +++ b/crates/perry-codegen/src/gc_effects/windows-x86_64.tsv @@ -573,6 +573,7 @@ js_class_capture_value Leaf js_class_capture_value_for_receiver Reenters js_class_capture_value_or Leaf js_class_computed_field_key Reenters +js_class_constructor_called Reenters js_class_env_current Reenters js_class_env_evaluate Reenters js_class_env_get Reenters @@ -589,8 +590,8 @@ js_class_field_set_fallback Reenters js_class_field_set_ic Reenters js_class_field_set_ic_fast Leaf js_class_field_set_ic_fast_miss Reenters -js_class_lexical_binding_get Leaf -js_class_lexical_binding_set Leaf +js_class_lexical_binding_get Reenters +js_class_lexical_binding_set Reenters js_class_method_bind Reenters js_class_method_bind_by_id Reenters js_class_method_snapshot_bind Reenters @@ -598,9 +599,12 @@ js_class_object_pin_parent Reenters js_class_object_refresh_capture_values Reenters js_class_prototype_method_value Reenters js_class_register_capture_values Leaf -js_class_register_static_field Leaf +js_class_register_static_field Reenters js_class_register_static_symbol Reenters +js_class_static_field_get Reenters +js_class_static_field_put Reenters js_class_static_method_call Reenters +js_class_value Reenters js_clear_exception Leaf js_clear_immediate_value Reenters js_clear_interval_value Reenters @@ -2141,7 +2145,7 @@ js_object_get_symbol_property_ic_miss Reenters js_object_get_symbol_then_field_ic_miss Reenters js_object_group_by Reenters js_object_has_own Reenters -js_object_has_own_symbol AllocOnly +js_object_has_own_symbol Reenters js_object_has_property Reenters js_object_is Reenters js_object_is_extensible Reenters @@ -2731,19 +2735,19 @@ js_register_class_generic_origin Leaf js_register_class_getter Reenters js_register_class_has_instance Leaf js_register_class_id Leaf -js_register_class_length Leaf +js_register_class_length Reenters js_register_class_method Leaf js_register_class_method_bind_length Leaf -js_register_class_name Leaf +js_register_class_name Reenters js_register_class_parent Leaf js_register_class_parent_dynamic Reenters js_register_class_setter Reenters js_register_class_source Leaf js_register_class_source_static Leaf -js_register_class_static_getter Leaf -js_register_class_static_method Leaf +js_register_class_static_getter Reenters +js_register_class_static_method Reenters js_register_class_static_method_bind_length Leaf -js_register_class_static_setter Leaf +js_register_class_static_setter Reenters js_register_class_string_member_order Leaf js_register_class_to_string_tag Leaf js_register_closure_arity Leaf @@ -3032,9 +3036,10 @@ js_sqlite_transaction Reenters js_state_get Reenters js_state_init Reenters js_state_set Reenters -js_static_this_arm_classref Leaf +js_static_this_arm_classref Reenters js_static_this_arm_value Leaf js_static_this_resolve Leaf +js_static_this_resolve_class Reenters js_stdlib_has_active_handles Reenters js_stdlib_init_dispatch Reenters js_stdlib_install_bundled_nodemailer Leaf From 4e5c40ee0de6214d6657add0fe9d765ac48db865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 20:57:52 +0000 Subject: [PATCH 23/29] feat(runtime): class static accessors are accessor properties of the class function object (3d) A ClassBody static accessor is now an accessor property of the class function object's own-property object: installed at mint (or when a computed key registers later) with the ClassBody attributes on its key and one reflected closure per half. Reflection, defineProperty, delete, Object.keys, propertyIsEnumerable, super.x and inherited reads all read that one property, so static_accessor_attrs.rs -- the (class_id, name) attribute table that existed only because a class value was not an object -- is deleted. Private static accessors are not properties and stay with the registration. #11521: a write to a getter-only static is rejected. Strict PutValue throws the TypeError node throws; Reflect.set's OrdinarySet walk sees the class accessor through its own-descriptor probe and returns false. A static walk steps only to a registered class parent, as class_static_member_value and class_prototype_get do: stepping to a builtin parent (`class ZodError extends Error`, id 0xFFFF_0001) minted a class function object for it, grew the class-value directory to 16M pages and made one minor GC scan them all (Zod +22%). Static symbol reads (class_static_symbol_lookup, class_static_symbol_keys_for_class) no longer mint either: a function object that was never created owns no properties. class_value_mint debug-asserts a compiled class id. Tests: test_gap_class_static_accessor_reflect.ts (G5's two bugs), test_gap_class_static_accessor_props.ts, test_gap_class_static_getter_only_set.ts (#11521), and a class_value unit test. --- .../class-static-accessor-properties.md | 8 + crates/perry-runtime/src/closure/props.rs | 79 +++- .../src/object/class_registry.rs | 13 +- .../object/class_registry/parent_static.rs | 111 ------ .../parent_static/private_and_dynamic.rs | 82 +++-- .../parent_static/static_accessor_call.rs | 159 +++++++- .../src/object/class_registry/registration.rs | 12 +- .../src/object/class_registry/state.rs | 8 +- .../class_registry/static_accessor_attrs.rs | 199 ---------- .../perry-runtime/src/object/class_value.rs | 347 +++++++++++++++++- .../perry-runtime/src/object/delete_rest.rs | 34 +- .../perry-runtime/src/object/descriptors.rs | 16 +- .../src/object/field_get_set/enumeration.rs | 10 +- .../src/object/field_get_set/has_property.rs | 3 +- .../object_ops/define_class_accessor.rs | 27 +- .../src/object/object_ops/has_own.rs | 16 +- .../perry-runtime/src/object/property_key.rs | 45 ++- crates/perry-runtime/src/proxy.rs | 40 +- crates/perry-runtime/src/symbol/properties.rs | 8 +- scripts/gc_runtime_root_holders.json | 6 - scripts/registry_lifetime_allowlist.json | 6 - .../test_gap_class_static_accessor_props.ts | 44 +++ .../test_gap_class_static_accessor_reflect.ts | 18 + .../test_gap_class_static_getter_only_set.ts | 31 ++ 24 files changed, 847 insertions(+), 475 deletions(-) create mode 100644 changelog.d/class-static-accessor-properties.md delete mode 100644 crates/perry-runtime/src/object/class_registry/static_accessor_attrs.rs create mode 100644 test-files/test_gap_class_static_accessor_props.ts create mode 100644 test-files/test_gap_class_static_accessor_reflect.ts create mode 100644 test-files/test_gap_class_static_getter_only_set.ts diff --git a/changelog.d/class-static-accessor-properties.md b/changelog.d/class-static-accessor-properties.md new file mode 100644 index 0000000000..6887d81c81 --- /dev/null +++ b/changelog.d/class-static-accessor-properties.md @@ -0,0 +1,8 @@ +Class static accessors (`static get x()` / `static set x(v)`) are now real +accessor properties of the class's function object, so reflection, +`defineProperty`, `delete`, `Object.keys`, `propertyIsEnumerable`, `super.x` +and inherited reads all see one property with its attributes. A write to a +getter-only static is rejected (#11521): strict assignment throws the +TypeError node throws, and `Reflect.set` returns `false`, on the class and on +subclasses. A static walk that reaches a builtin parent (`class E extends +Error`) stops there instead of minting a class function object for it. diff --git a/crates/perry-runtime/src/closure/props.rs b/crates/perry-runtime/src/closure/props.rs index 88eaacdcac..3840a4b096 100644 --- a/crates/perry-runtime/src/closure/props.rs +++ b/crates/perry-runtime/src/closure/props.rs @@ -36,7 +36,7 @@ pub(crate) unsafe fn bag_of(ptr: usize) -> *mut ObjectHeader { } /// Allocate the bag if absent and install it with the store barrier. -unsafe fn bag_ensure(ptr: usize) -> *mut ObjectHeader { +pub(crate) unsafe fn bag_ensure(ptr: usize) -> *mut ObjectHeader { let existing = bag_of(ptr); if !existing.is_null() { return existing; @@ -61,11 +61,14 @@ unsafe fn object_own_get(obj: *const ObjectHeader, key: &[u8]) -> Option { // come from the same descriptor. if let Some(d) = crate::object::shapes::object_shape_descriptor(obj) { if d.object_kind == crate::object::shapes::ShapeObjectKind::Ordinary && d.keys != 0 { - let slot = crate::object::keys_find_slot_by_bytes_resolved( - d.keys as usize as *const crate::array::ArrayHeader, - d.logical_key_count, - key, - )?; + let keys = d.keys as usize as *const crate::array::ArrayHeader; + let slot = + crate::object::keys_find_slot_by_bytes_resolved(keys, d.logical_key_count, key)?; + // An accessor key's slot holds its getter/setter pair, never a + // data value. + if crate::object::key_attrs::key_is_accessor_at(keys, slot as u32) { + return None; + } let value = crate::object::object_field_at_with_live(obj, slot, d.live_inline_slot_count); if value.bits() == crate::value::TAG_HOLE { @@ -80,6 +83,9 @@ unsafe fn object_own_get(obj: *const ObjectHeader, key: &[u8]) -> Option { return None; } let slot = crate::object::keys_find_slot_by_bytes_resolved(arr, keys.count(), key)?; + if crate::object::key_attrs::key_is_accessor_at(arr, slot as u32) { + return None; + } let live = crate::object::object_live_slot_count(obj); let value = crate::object::object_field_at_with_live(obj, slot, live); if value.bits() == crate::value::TAG_HOLE { @@ -122,7 +128,7 @@ pub(crate) unsafe fn bag_set(ptr: usize, key: &str, value: f64) { /// `ptr` is a proven, live closure cell. pub(crate) unsafe fn bag_remove(ptr: usize, key: &str) -> bool { let bag = bag_of(ptr); - if bag.is_null() || object_own_get(bag, key.as_bytes()).is_none() { + if bag.is_null() || !bag_has_own(ptr, key.as_bytes()) { return false; } let _no_move = crate::gc::GcSuppressScope::new(); @@ -131,6 +137,61 @@ pub(crate) unsafe fn bag_remove(ptr: usize, key: &str) -> bool { true } +/// Does the function own `key` — a data OR an accessor property? +/// +/// # Safety +/// `ptr` is a proven, live closure cell. +pub(crate) unsafe fn bag_has_own(ptr: usize, key: &[u8]) -> bool { + let bag = bag_of(ptr); + if bag.is_null() { + return false; + } + let keys = crate::object::object_keys(bag); + let arr = keys.arr(); + if arr.is_null() { + return false; + } + let Some(slot) = crate::object::keys_find_slot_by_bytes_resolved(arr, keys.count(), key) else { + return false; + }; + crate::object::key_attrs::key_is_accessor_at(arr, slot as u32) + || crate::object::object_field_at_with_live( + bag, + slot, + crate::object::object_live_slot_count(bag), + ) + .bits() + != crate::value::TAG_HOLE +} + +/// The function's own ACCESSOR property names, in creation order. +/// +/// # Safety +/// `ptr` is a proven, live closure cell. +pub(crate) unsafe fn bag_accessor_names(ptr: usize) -> Vec { + let bag = bag_of(ptr); + if bag.is_null() { + return Vec::new(); + } + let keys = crate::object::object_keys(bag); + let arr = keys.arr(); + if arr.is_null() { + return Vec::new(); + } + let mut out = Vec::new(); + for i in 0..keys.count() { + if !crate::object::key_attrs::key_is_accessor_at(arr, i) { + continue; + } + let key = JSValue::from_bits(crate::array::js_array_get_f64(arr, i).to_bits()); + let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + if let Some(bytes) = crate::string::js_string_key_bytes(key, &mut scratch) { + out.push(String::from_utf8_lossy(bytes).into_owned()); + } + } + out +} + /// Every own data property in ECMA-262 own-key order: integer indices /// ascending, then other strings in creation order. /// @@ -151,7 +212,9 @@ pub(crate) unsafe fn bag_snapshot(ptr: usize) -> Vec<(String, f64)> { let mut strings: Vec<(String, f64)> = Vec::new(); for i in 0..keys.count() { let value = crate::object::object_field_at_with_live(bag, i, live); - if value.bits() == crate::value::TAG_HOLE { + if value.bits() == crate::value::TAG_HOLE + || crate::object::key_attrs::key_is_accessor_at(arr, i) + { continue; } let key = JSValue::from_bits(crate::array::js_array_get_f64(arr, i).to_bits()); diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index 279fb64e26..dcbfb60604 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -68,16 +68,13 @@ mod prototype_methods; pub(crate) mod prototype_objects; mod registration; mod state; -mod static_accessor_attrs; pub(crate) mod verdict_classes; mod vm_brand; -// ── static_accessor_attrs.rs ──────────────────────────────────────────────── -pub(crate) use static_accessor_attrs::{ - set_static_accessor_attrs, static_accessor_attrs, static_accessor_attrs_in_use, - static_accessor_descriptor, static_declared_accessor_ptrs, static_enumerable_accessor_names, - CLASS_ACCESSOR_DEFAULT_ATTRS, -}; +// Static accessors are accessor properties of the class function object +// (`object::class_value`); the ClassBody defaults are shared with instance +// accessors. +pub(crate) use crate::object::class_value::CLASS_ACCESSOR_DEFAULT_ATTRS; // ── state.rs ──────────────────────────────────────────────────────────────── pub(crate) use state::async_resource_prototype_value; @@ -201,7 +198,7 @@ pub(crate) use gc_roots::{ // ── registration.rs ───────────────────────────────────────────────────────── pub(crate) use registration::{ class_accessor_function_value, class_accessor_source_func_ptr, class_own_accessor_ptrs, - class_own_static_accessor_ptrs, invalidate_class_string_member_order, + class_registered_static_accessor_ptrs, invalidate_class_string_member_order, }; pub use registration::{ is_class_id_registered, js_register_class_getter, js_register_class_method, 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 47e1fc3b30..c9c75951d1 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -1136,117 +1136,6 @@ pub(crate) unsafe fn class_symbol_setter_apply( }) } -pub(crate) unsafe fn class_static_accessor_getter_value( - class_id: u32, - name: &str, - receiver: f64, -) -> Option { - let guard = CLASS_STATIC_ACCESSORS.read().ok(); - let map = guard.as_ref().and_then(|guard| guard.as_ref()); - let mut cid = class_id; - let mut depth = 0usize; - while cid != 0 && depth < 32 { - // A descriptor installed by `defineProperty` replaces an existing - // class-body accessor at the same inheritance level. - if let Some(result) = class_dynamic_static_accessor_getter_value(cid, name, receiver) { - return Some(result); - } - if let Some(accessors) = map.and_then(|map| map.get(&cid)) { - if let Some(&(getter, _)) = accessors.get(name) { - if getter == 0 { - return Some(f64::from_bits(crate::value::TAG_UNDEFINED)); - } - // #10911: when this getter was reached by walking the STATIC - // prototype chain -- a subclass reading an accessor declared - // on its parent class OBJECT -- `receiver` is that parent, the - // object the getter lives on. `resolve_proto_chain_field_inner` - // stashes the class the read actually started from, exactly as - // it does for instance getters (see `class_getter_this`), and - // spec OrdinaryGet threads that Receiver through. Bind `this` - // to it, or `Sub.accessor` runs with `this === Base`. - // - // Effect's `static get ast() { return getClassSchema(this).ast }` - // is this shape: the schema memoised against the base class, so - // decoded errors were built from the base and were not - // `instanceof` their own class (#10891). - // - // `this` and the capture/private OWNER are two different - // things here and must not be collapsed: `this` is the class - // the read started from, while the owner is the evaluation the - // getter was FOUND on -- the object whose `__perry_ctor_caps` - // hold its captured variables and whose brand gates `#x`. - // `js_class_capture_value_for_receiver` prefers the owner, so - // binding it to the subclass would lose every capture. - let owner = receiver; - let receiver = crate::object::field_get_set::accessor_receiver_override_take() - .unwrap_or(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(owner); - let f: extern "C" fn() -> f64 = std::mem::transmute(getter); - let result = f(); - crate::object::static_private_owner_pop(); - crate::object::static_this_disarm(); - return Some(result); - } - } - match get_parent_class_id(cid) { - Some(p) if p != 0 && p != cid => { - cid = p; - depth += 1; - } - _ => break, - } - } - None -} - -pub(crate) unsafe fn class_static_accessor_setter_apply( - class_id: u32, - name: &str, - receiver: f64, - value: f64, -) -> bool { - let guard = CLASS_STATIC_ACCESSORS.read().ok(); - let map = guard.as_ref().and_then(|guard| guard.as_ref()); - let mut cid = class_id; - let mut depth = 0usize; - while cid != 0 && depth < 32 { - if let Some(applied) = - class_dynamic_static_accessor_setter_apply(cid, name, receiver, value) - { - return applied; - } - if let Some(accessors) = map.and_then(|map| map.get(&cid)) { - if let Some(&(_, setter)) = accessors.get(name) { - if setter != 0 { - // 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::static_private_owner_pop(); - crate::object::static_this_disarm(); - } - return true; - } - } - match get_parent_class_id(cid) { - Some(p) if p != 0 && p != cid => { - cid = p; - depth += 1; - } - _ => break, - } - } - false -} - /// Apply an instance `set name(v)` accessor from the class vtable chain, /// invoking it with the `(this, value)` calling convention class setters use. /// Returns `true` if a setter was found and called. Used when a write targets diff --git a/crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs b/crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs index 33bb296b6e..3af1e17e3a 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs @@ -114,12 +114,36 @@ pub(crate) fn register_class_dynamic_static_accessor( if owner == 0 { return; } - let key = dynamic_static_accessor_storage_key(owner, name); - // A property is data OR accessor: redefining an own static data property - // as an accessor removes the data slot from the class function object. if !is_class_object_ptr(owner as *const u8) { - crate::object::class_value::class_static_remove(class_id, name); + // The class function object: an accessor property of its own + // property object (a data property of that name becomes it). An + // omitted half or attribute keeps the current one (a ClassBody half + // included); a new property defaults them to absent / false. + let existing = crate::object::class_value::class_static_own_accessor(class_id, name); + let have = existing.map(|(acc, _, _)| acc).unwrap_or_default(); + let acc = crate::object::accessor_pair::Accessor { + get: get_bits.map(|_| get.get_nanbox_u64()).unwrap_or(have.get), + set: set_bits.map(|_| set.get_nanbox_u64()).unwrap_or(have.set), + raw_get: if get_bits.is_some() { 0 } else { have.raw_get }, + raw_set: if set_bits.is_some() { 0 } else { have.raw_set }, + }; + let enumerable = enumerable + .or(existing.map(|(_, e, _)| e)) + .unwrap_or(false); + let configurable = configurable + .or(existing.map(|(_, _, c)| c)) + .unwrap_or(false); + crate::object::class_value::class_static_define_accessor( + class_id, + name, + acc, + enumerable, + configurable, + ); + crate::object::class_registry::class_static_alias_sync(class_id, name); + return; } + let key = dynamic_static_accessor_storage_key(owner, name); let existing = crate::object::get_accessor_descriptor(owner, &key).unwrap_or_default(); crate::object::set_accessor_descriptor( owner, @@ -133,28 +157,19 @@ pub(crate) fn register_class_dynamic_static_accessor( .unwrap_or(existing.set), }, ); - let existing_attrs = if is_class_object_ptr(owner as *const u8) { - crate::object::get_property_attrs(owner, &key) - .map(|attrs| (attrs.enumerable(), attrs.configurable())) - } else { - class_static_defined_attrs(class_id, name) - .map(|(_, enumerable, configurable)| (enumerable, configurable)) - }; + let existing_attrs = crate::object::get_property_attrs(owner, &key) + .map(|attrs| (attrs.enumerable(), attrs.configurable())); let enumerable = enumerable .or_else(|| existing_attrs.map(|attrs| attrs.0)) .unwrap_or(false); let configurable = configurable .or_else(|| existing_attrs.map(|attrs| attrs.1)) .unwrap_or(false); - if is_class_object_ptr(owner as *const u8) { - crate::object::set_property_attrs( - owner, - key, - crate::object::PropertyAttrs::new(false, enumerable, configurable), - ); - } else { - class_static_set_defined_attrs(class_id, name, false, enumerable, configurable); - } + crate::object::set_property_attrs( + owner, + key, + crate::object::PropertyAttrs::new(false, enumerable, configurable), + ); crate::object::class_registry::class_static_alias_sync(class_id, name); } @@ -172,16 +187,21 @@ pub(crate) fn class_dynamic_static_accessor_descriptor( if owner == 0 { return None; } + if !is_class_object_ptr(owner as *const u8) { + let (acc, enumerable, configurable) = + crate::object::class_value::class_static_own_accessor(class_id, name)?; + return Some(( + crate::object::AccessorDescriptor { + get: acc.get, + set: acc.set, + }, + crate::object::PropertyAttrs::new(false, enumerable, configurable), + )); + } let key = dynamic_static_accessor_storage_key(owner, name); let descriptor = crate::object::get_accessor_descriptor(owner, &key)?; - let attrs = if is_class_object_ptr(owner as *const u8) { - crate::object::get_property_attrs(owner, &key) - } else { - class_static_defined_attrs(class_id, name).map(|(_, enumerable, configurable)| { - crate::object::PropertyAttrs::new(false, enumerable, configurable) - }) - } - .unwrap_or(crate::object::PropertyAttrs::new(false, false, false)); + let attrs = crate::object::get_property_attrs(owner, &key) + .unwrap_or(crate::object::PropertyAttrs::new(false, false, false)); Some((descriptor, attrs)) } @@ -193,7 +213,9 @@ pub(crate) unsafe fn class_dynamic_static_accessor_getter_value( let scope = crate::gc::RuntimeHandleScope::new(); let receiver = scope.root_nanbox_f64(receiver); let owner = dynamic_static_accessor_owner(class_id, receiver.get_nanbox_f64()); - let descriptor = (owner != 0) + // The class function object's accessors are its own properties + // (`class_static_accessor_getter_value` reads them). + let descriptor = (owner != 0 && is_class_object_ptr(owner as *const u8)) .then(|| { crate::object::get_accessor_descriptor( owner, @@ -222,7 +244,7 @@ pub(crate) unsafe fn class_dynamic_static_accessor_setter_apply( let receiver = scope.root_nanbox_f64(receiver); let value = scope.root_nanbox_f64(value); let owner = dynamic_static_accessor_owner(class_id, receiver.get_nanbox_f64()); - let descriptor = (owner != 0) + let descriptor = (owner != 0 && is_class_object_ptr(owner as *const u8)) .then(|| { crate::object::get_accessor_descriptor( owner, diff --git a/crates/perry-runtime/src/object/class_registry/parent_static/static_accessor_call.rs b/crates/perry-runtime/src/object/class_registry/parent_static/static_accessor_call.rs index 892c1b087c..d919359b38 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static/static_accessor_call.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static/static_accessor_call.rs @@ -5,11 +5,11 @@ pub(crate) fn static_accessor_in_chain(class_id: u32, name: &str) -> bool { let mut cid = class_id; let mut depth = 0usize; while cid != 0 && depth < 32 { - if class_own_static_accessor_ptrs(cid, name).is_some() { + if crate::object::class_value::class_static_has_own_accessor(cid, name) { return true; } match get_parent_class_id(cid) { - Some(p) if p != 0 && p != cid => { + Some(p) if p != 0 && p != cid && crate::object::is_class_id_registered(p) => { cid = p; depth += 1; } @@ -108,3 +108,158 @@ pub(crate) unsafe fn try_static_accessor_value_call( crate::object::js_implicit_this_set(prev_this.get_nanbox_f64()); Some(result) } + +pub(crate) unsafe fn class_static_accessor_getter_value( + class_id: u32, + name: &str, + receiver: f64, +) -> Option { + if name.starts_with('#') { + return private_static_accessor_getter_value(class_id, name, receiver); + } + let mut cid = class_id; + let mut depth = 0usize; + while cid != 0 && depth < 32 { + // A per-evaluation class object's own `defineProperty` accessor. + if let Some(result) = class_dynamic_static_accessor_getter_value(cid, name, receiver) { + return Some(result); + } + // The class function object's own accessor property (ClassBody or + // `defineProperty`). #10911: reached through the STATIC prototype + // chain, `receiver` is the class it was found on (the capture/private + // owner); `this` is the class the read started from, stashed as the + // accessor-receiver override (effect's `static get ast()`, #10891). + if let Some((acc, _, _)) = crate::object::class_value::class_static_own_accessor(cid, name) + { + return Some(crate::object::class_value::class_static_accessor_call_get( + acc, receiver, + )); + } + match get_parent_class_id(cid) { + Some(p) if p != 0 && p != cid && crate::object::is_class_id_registered(p) => { + cid = p; + depth += 1; + } + _ => break, + } + } + None +} + +/// A private static accessor (`static get #x()`): not a property, so it is +/// read from the class's registration and never inherited through a public +/// lookup. +unsafe fn private_static_accessor_getter_value( + class_id: u32, + name: &str, + receiver: f64, +) -> Option { + let mut cid = class_id; + let mut depth = 0usize; + while cid != 0 && depth < 32 { + if let Some((getter, _)) = class_registered_static_accessor_ptrs(cid, name) { + if getter == 0 { + return Some(f64::from_bits(crate::value::TAG_UNDEFINED)); + } + let owner = receiver; + let receiver = + crate::object::field_get_set::accessor_receiver_override_take().unwrap_or(receiver); + crate::object::static_this_arm_if_unarmed(receiver); + crate::object::static_private_owner_push(owner); + let f: extern "C" fn() -> f64 = std::mem::transmute(getter); + let result = f(); + crate::object::static_private_owner_pop(); + crate::object::static_this_disarm(); + return Some(result); + } + match get_parent_class_id(cid) { + Some(p) if p != 0 && p != cid && crate::object::is_class_id_registered(p) => { + cid = p; + depth += 1; + } + _ => break, + } + } + None +} + +/// `C.name = value` where the class (or an ancestor) has an accessor `name`: +/// its setter runs and `true` is returned. A getter-only accessor refuses the +/// write — strict-mode [[Set]] throws a TypeError (#11521); only a private +/// `#x` reports `true` without a setter, its caller decides. `false` when no +/// accessor of that name is on the chain. +pub(crate) unsafe fn class_static_accessor_setter_apply( + class_id: u32, + name: &str, + receiver: f64, + value: f64, +) -> bool { + if name.starts_with('#') { + return private_static_accessor_setter_apply(class_id, name, receiver, value); + } + let mut cid = class_id; + let mut depth = 0usize; + while cid != 0 && depth < 32 { + if let Some(applied) = + class_dynamic_static_accessor_setter_apply(cid, name, receiver, value) + { + if !applied { + throw_static_getter_only(class_id, name); + } + return true; + } + if let Some((acc, _, _)) = crate::object::class_value::class_static_own_accessor(cid, name) + { + if !crate::object::class_value::class_static_accessor_call_set(acc, receiver, value) { + throw_static_getter_only(class_id, name); + } + return true; + } + match get_parent_class_id(cid) { + Some(p) if p != 0 && p != cid && crate::object::is_class_id_registered(p) => { + cid = p; + depth += 1; + } + _ => break, + } + } + false +} + +fn throw_static_getter_only(class_id: u32, name: &str) -> ! { + let class_name = class_name_for_id(class_id).unwrap_or_default(); + crate::collection_iter::throw_type_error(&format!( + "Cannot set property {name} of [class {class_name}] which has only a getter" + )) +} + +unsafe fn private_static_accessor_setter_apply( + class_id: u32, + name: &str, + receiver: f64, + value: f64, +) -> bool { + let mut cid = class_id; + let mut depth = 0usize; + while cid != 0 && depth < 32 { + if let Some((_, setter)) = class_registered_static_accessor_ptrs(cid, name) { + if setter != 0 { + 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::static_private_owner_pop(); + crate::object::static_this_disarm(); + } + return true; + } + match get_parent_class_id(cid) { + Some(p) if p != 0 && p != cid && crate::object::is_class_id_registered(p) => { + cid = p; + depth += 1; + } + _ => break, + } + } + false +} diff --git a/crates/perry-runtime/src/object/class_registry/registration.rs b/crates/perry-runtime/src/object/class_registry/registration.rs index a835e38fba..4ab6a4c3eb 100644 --- a/crates/perry-runtime/src/object/class_registry/registration.rs +++ b/crates/perry-runtime/src/object/class_registry/registration.rs @@ -190,9 +190,15 @@ pub(crate) fn class_own_accessor_ptrs(class_id: u32, name: &str) -> Option<(usiz (decl.get != 0 || decl.set != 0).then_some((decl.get, decl.set)) } -/// Own static accessor func_ptrs for the class *constructor*. Mirrors -/// `class_own_accessor_ptrs` against `CLASS_STATIC_ACCESSORS`. -pub(crate) fn class_own_static_accessor_ptrs(class_id: u32, name: &str) -> Option<(usize, usize)> { +/// The compiled entries of the ClassBody static accessor `name` of `class_id` +/// as REGISTERED (`CLASS_STATIC_ACCESSORS`): the input the class function +/// object's accessor property is built from, not the property itself — a +/// deleted or redefined accessor is still registered. Private (`#x`) static +/// accessors live only here. +pub(crate) fn class_registered_static_accessor_ptrs( + class_id: u32, + name: &str, +) -> Option<(usize, usize)> { let guard = CLASS_STATIC_ACCESSORS.read().ok()?; let reg = guard.as_ref()?; let pair = reg.get(&class_id)?.get(name).copied()?; diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index f55344c3a5..da95bbd52f 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -127,13 +127,7 @@ pub(crate) fn class_static_alias_sync(class_id: u32, name: &str) { }; let plain = !class_is_key_deleted(class_id, name) && class_static_defined_attrs(class_id, name).is_none_or(|(writable, _, _)| writable) - && class_own_static_accessor_ptrs(class_id, name).is_none() - && super::class_dynamic_static_accessor_descriptor( - class_id, - name, - crate::object::class_value::class_value(class_id), - ) - .is_none(); + && !crate::object::class_value::class_static_has_own_accessor(class_id, name); let value = plain .then(|| crate::object::class_value::class_static_get(class_id, name)) .flatten() diff --git a/crates/perry-runtime/src/object/class_registry/static_accessor_attrs.rs b/crates/perry-runtime/src/object/class_registry/static_accessor_attrs.rs deleted file mode 100644 index d32428edc5..0000000000 --- a/crates/perry-runtime/src/object/class_registry/static_accessor_attrs.rs +++ /dev/null @@ -1,199 +0,0 @@ -//! Reflective attributes of DECLARED STATIC class accessors (#10480). -//! -//! A ClassBody `static get x() {}` is an own property of the constructor `C`, -//! and a class constructor is a ClassRef value, not an object: its accessors -//! live in `CLASS_STATIC_ACCESSORS`, which records only the two function -//! pointers. This table holds what a generic descriptor -//! (`Object.defineProperty(C, "x", { enumerable: true })`) applied, keyed by -//! `(class_id, name)`; absence means the ClassBody defaults -//! (`enumerable: false`, `configurable: true`). -//! -//! Instance accessors are not here: they are real accessor properties of the -//! class's decl prototype (`decl_accessors.rs`), whose attributes live with -//! the prototype's keys like any other property's. -//! -//! [`static_accessor_attrs_in_use`] lets the enumeration paths skip the -//! lookup with one load. The values are booleans: nothing here is a GC root. - -use super::*; -use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, Ordering}; - -crate::perry_thread_local! { - static STATIC_ACCESSOR_ATTRS: std::cell::RefCell> = - std::cell::RefCell::new(HashMap::new()); -} - -/// Sticky: set by the first [`set_static_accessor_attrs`]. Only a hint that -/// the table may be non-empty — never cleared, so a stale `true` merely costs -/// a lookup. -static STATIC_ACCESSOR_ATTRS_IN_USE: AtomicBool = AtomicBool::new(false); - -/// ClassBody defaults for an accessor: `(enumerable, configurable)`. -pub(crate) const CLASS_ACCESSOR_DEFAULT_ATTRS: (bool, bool) = (false, true); - -#[inline] -pub(crate) fn static_accessor_attrs_in_use() -> bool { - STATIC_ACCESSOR_ATTRS_IN_USE.load(Ordering::Relaxed) -} - -/// `(enumerable, configurable)` of the declared static accessor `name`. -pub(crate) fn static_accessor_attrs(class_id: u32, name: &str) -> (bool, bool) { - if !static_accessor_attrs_in_use() { - return CLASS_ACCESSOR_DEFAULT_ATTRS; - } - STATIC_ACCESSOR_ATTRS.with(|table| { - table - .borrow() - .get(&(class_id, name.to_string())) - .copied() - .unwrap_or(CLASS_ACCESSOR_DEFAULT_ATTRS) - }) -} - -pub(crate) fn set_static_accessor_attrs( - class_id: u32, - name: &str, - enumerable: bool, - configurable: bool, -) { - STATIC_ACCESSOR_ATTRS_IN_USE.store(true, Ordering::Relaxed); - STATIC_ACCESSOR_ATTRS.with(|table| { - table - .borrow_mut() - .insert((class_id, name.to_string()), (enumerable, configurable)); - }); -} - -/// Raw `(getter, setter)` func_ptrs of a live own declared static accessor — -/// `None` for a method, a field, an inherited accessor, or one `delete` -/// removed. -pub(crate) fn static_declared_accessor_ptrs(class_id: u32, name: &str) -> Option<(usize, usize)> { - if class_is_key_deleted(class_id, name) { - return None; - } - class_own_static_accessor_ptrs(class_id, name) -} - -/// `Object.getOwnPropertyDescriptor(C, name)` for a declared static accessor. -/// The getter value is rooted across the setter value's allocation. -pub(crate) unsafe fn static_accessor_descriptor( - class_id: u32, - name: &str, - getter: usize, - setter: usize, -) -> f64 { - let scope = crate::gc::RuntimeHandleScope::new(); - let get = scope.root_nanbox_f64(class_accessor_function_value(getter, false, name)); - let set = class_accessor_function_value(setter, true, name); - let (enumerable, configurable) = static_accessor_attrs(class_id, name); - crate::object::descriptors::build_accessor_descriptor( - get.get_nanbox_f64(), - set, - enumerable, - configurable, - ) -} - -/// The class's own declared static accessors that are currently enumerable, -/// in ClassBody order. Empty (without walking the class) unless some static -/// accessor of this class was made enumerable. -pub(crate) fn static_enumerable_accessor_names(class_id: u32) -> Vec { - if !static_accessor_attrs_in_use() { - return Vec::new(); - } - let any = STATIC_ACCESSOR_ATTRS.with(|table| { - table - .borrow() - .iter() - .any(|((cid, _), &(enumerable, _))| *cid == class_id && enumerable) - }); - if !any { - return Vec::new(); - } - class_own_string_member_names(class_id, true) - .into_iter() - .filter(|name| { - static_declared_accessor_ptrs(class_id, name).is_some() - && static_accessor_attrs(class_id, name).0 - }) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - extern "C" fn getter() -> f64 { - 0.0 - } - - extern "C" fn setter(_value: f64) -> f64 { - 0.0 - } - - unsafe fn register(class_id: u32, name: &str, with_setter: bool, order: i64) { - js_register_class_static_getter( - class_id as i64, - name.as_ptr(), - name.len() as i64, - getter as *const () as usize as i64, - ); - if with_setter { - js_register_class_static_setter( - class_id as i64, - name.as_ptr(), - name.len() as i64, - setter as *const () as usize as i64, - ); - } - js_register_class_string_member_order( - class_id as i64, - name.as_ptr(), - name.len() as i64, - 1, - order, - ); - } - - #[test] - fn unrecorded_static_accessor_keeps_classbody_defaults() { - assert_eq!(static_accessor_attrs(0x7c48_0001, "never"), (false, true)); - } - - #[test] - fn static_attrs_are_keyed_by_class_and_name() { - let cid = 0x7c48_0002; - set_static_accessor_attrs(cid, "x", true, false); - assert!(static_accessor_attrs_in_use()); - assert_eq!(static_accessor_attrs(cid, "x"), (true, false)); - assert_eq!(static_accessor_attrs(cid, "y"), (false, true)); - assert_eq!(static_accessor_attrs(cid + 1, "x"), (false, true)); - } - - /// Only live declared static accessors qualify: a deleted one, a name the - /// class never declared, and a non-enumerable one are all excluded, and - /// the survivors come back in ClassBody order rather than insertion order. - #[test] - fn static_enumerable_accessor_names_follow_classbody_order() { - let cid = 0x7c48_0003; - unsafe { - register(cid, "b", true, 10); - register(cid, "a", false, 20); - register(cid, "c", true, 30); - register(cid, "gone", true, 40); - } - set_static_accessor_attrs(cid, "a", true, true); - set_static_accessor_attrs(cid, "b", true, true); - set_static_accessor_attrs(cid, "c", false, true); - set_static_accessor_attrs(cid, "gone", true, true); - set_static_accessor_attrs(cid, "undeclared", true, true); - class_mark_key_deleted(cid, "gone"); - assert_eq!( - static_enumerable_accessor_names(cid), - vec!["b".to_string(), "a".to_string()] - ); - assert_eq!(static_declared_accessor_ptrs(cid, "gone"), None); - assert!(static_declared_accessor_ptrs(cid, "a").is_some_and(|(g, s)| g != 0 && s == 0)); - } -} diff --git a/crates/perry-runtime/src/object/class_value.rs b/crates/perry-runtime/src/object/class_value.rs index ea584c15ec..44e6b60f3a 100644 --- a/crates/perry-runtime/src/object/class_value.rs +++ b/crates/perry-runtime/src/object/class_value.rs @@ -225,6 +225,10 @@ fn class_value_slot(class_id: u32) -> *mut *mut ClosureHeader { #[cold] #[inline(never)] fn class_value_mint(class_id: u32) -> *mut ClosureHeader { + debug_assert!( + class_id != 0 && class_id < 0x7FFF_FF00, + "a class function object belongs to a compiled class id, never a builtin or synthetic band: {class_id:#x}" + ); let _no_collect = crate::gc::GcSuppressScope::new(); let payload = crate::closure::closure_payload_size(1); let ptr = crate::arena::arena_alloc_gc_old_born_tenured( @@ -257,6 +261,10 @@ fn class_value_mint(class_id: u32) -> *mut ClosureHeader { for key in INTRINSIC_OWN_DATA_KEYS { install_intrinsic_own_data(class_id, key); } + // ClassBody static accessors, in ClassBody order. + for key in super::class_registry::class_own_string_member_names(class_id, true) { + install_declared_static_accessor(class_id, &key); + } ptr } @@ -284,7 +292,7 @@ fn intrinsic_own_data_value(class_id: u32, key: &str) -> Option { /// not the intrinsic data property, is the class's own `key`. fn static_member_owns(class_id: u32, key: &str) -> bool { super::class_registry::class_has_own_static_method(class_id, key) - || super::class_registry::class_own_static_accessor_ptrs(class_id, key).is_some() + || super::class_registry::class_registered_static_accessor_ptrs(class_id, key).is_some() } /// Is own `key` of class `class_id` still the intrinsic data property (not @@ -336,10 +344,18 @@ pub(crate) fn note_static_field_defined(class_id: u32, key: &str) { /// property in line. A key the program already redefined or deleted is left /// alone. pub(crate) fn note_intrinsic_registration(class_id: u32, key: &str) { - if !INTRINSIC_OWN_DATA_KEYS.contains(&key) || class_value_cached(class_id).is_none() { + if class_value_cached(class_id).is_none() { return; } let _no_collect = crate::gc::GcSuppressScope::new(); + // A ClassBody static accessor registered after the object exists (a + // computed key registers when the class definition evaluates). + if super::class_registry::class_registered_static_accessor_ptrs(class_id, key).is_some() { + install_declared_static_accessor(class_id, key); + } + if !INTRINSIC_OWN_DATA_KEYS.contains(&key) { + return; + } if holds_intrinsic(class_id, key) { if static_member_owns(class_id, key) { class_static_remove(class_id, key); @@ -354,6 +370,15 @@ pub(crate) fn note_intrinsic_registration(class_id: u32, key: &str) { } } +/// The class function object for `class_id` if this agent has minted it. +/// A read that finds none has its answer without minting one: an object that +/// was never created owns no properties. (A builtin parent such as `Error` +/// never gets a class function object, so reads walking to it must use this.) +#[inline] +pub(crate) fn class_value_if_minted(class_id: u32) -> Option<*mut ClosureHeader> { + class_value_cached(class_id) +} + /// The class function object for `class_id` on this agent (minted on first /// use). `class_id` must be a registered class. #[inline] @@ -458,24 +483,16 @@ pub unsafe extern "C" fn js_class_static_field_put( crate::object::js_object_set_field_by_name(receiver, key, value); } -/// [[Get]] of `key` on class `class_id`'s [[Prototype]], `receiver` as the -/// receiver: the continuation of a read of a key the class does not own -/// (e.g. its own `name` was deleted — `Sub.name` then reads `Base.name`, a -/// base class reads `Function.prototype.name`). The [[Prototype]] is the +/// The address of class `class_id`'s [[Prototype]] (0 when null): the /// recorded one (`Object.setPrototypeOf(C, p)`), else the parent class's /// function object, else the parent function (`extends `), else /// %Function.prototype%. -pub(crate) fn class_prototype_get( - class_id: u32, - key: *const crate::StringHeader, - receiver: f64, -) -> crate::value::JSValue { - use crate::value::JSValue; +pub(crate) fn class_prototype_addr(class_id: u32) -> usize { if super::class_registry::class_static_prototype_is_nulled(class_id) { - return JSValue::undefined(); + return 0; } let proto = super::class_registry::class_static_prototype(class_id) as usize; - let proto = if proto != 0 { + if proto != 0 { proto } else if let Some(parent) = super::get_parent_class_id(class_id) .filter(|&p| p != 0 && p != class_id && super::is_class_id_registered(p)) @@ -486,7 +503,23 @@ pub(crate) fn class_prototype_get( } else { crate::closure::shape::FUNCTION_PROTOTYPE_PTR.load(std::sync::atomic::Ordering::Acquire) as usize - }; + } +} + +/// [[Get]] of `key` on class `class_id`'s [[Prototype]], `receiver` as the +/// receiver: the continuation of a read of a key the class does not own +/// (e.g. its own `name` was deleted — `Sub.name` then reads `Base.name`, a +/// base class reads `Function.prototype.name`). The [[Prototype]] is the +/// recorded one (`Object.setPrototypeOf(C, p)`), else the parent class's +/// function object, else the parent function (`extends `), else +/// %Function.prototype%. +pub(crate) fn class_prototype_get( + class_id: u32, + key: *const crate::StringHeader, + receiver: f64, +) -> crate::value::JSValue { + use crate::value::JSValue; + let proto = class_prototype_addr(class_id); if proto == 0 { return JSValue::undefined(); } @@ -496,6 +529,208 @@ pub(crate) fn class_prototype_get( value } +// --------------------------------------------------------------------------- +// Static accessors: accessor properties of the function object. +// --------------------------------------------------------------------------- + +/// ClassBody defaults for an accessor: `(enumerable, configurable)`. +pub(crate) const CLASS_ACCESSOR_DEFAULT_ATTRS: (bool, bool) = (false, true); + +/// Install — or refresh, when a half arrives later — the ClassBody static +/// accessor `name` of `class_id` as an accessor property of its function +/// object's own-property object: the pair holds the reflected closures and +/// the compiled static entries (`fn() -> value` / `fn(v)`, `this` armed by +/// the caller — NOT the instance `fn(this)` convention; only this module and +/// its callers read a class function object's pairs). A half whose compiled +/// entry is unchanged keeps its closure, so reflection hands out the same +/// function every time; attributes a `defineProperty` set are kept. +/// Private (`#x`) accessors are not properties and are never installed. +fn install_declared_static_accessor(class_id: u32, name: &str) { + if name.starts_with('#') { + return; + } + let Some((raw_get, raw_set)) = + super::class_registry::class_registered_static_accessor_ptrs(class_id, name) + else { + return; + }; + let _no_collect = crate::gc::GcSuppressScope::new(); + let existing = class_static_own_accessor(class_id, name); + let (have, enumerable, configurable) = match existing { + Some((acc, e, c)) => (acc, e, c), + None => ( + crate::object::accessor_pair::Accessor::default(), + CLASS_ACCESSOR_DEFAULT_ATTRS.0, + CLASS_ACCESSOR_DEFAULT_ATTRS.1, + ), + }; + let half = |raw: usize, have_raw: usize, have: u64, is_setter: bool| -> u64 { + if raw == 0 { + 0 + } else if raw == have_raw && have != 0 { + have + } else { + super::class_registry::class_accessor_function_value(raw, is_setter, name).to_bits() + } + }; + let get = half(raw_get, have.raw_get, have.get, false); + let set = half(raw_set, have.raw_set, have.set, true); + class_static_define_accessor( + class_id, + name, + crate::object::accessor_pair::Accessor { + get, + set, + raw_get, + raw_set, + }, + enumerable, + configurable, + ); +} + +/// Class `class_id`'s own accessor property `name` (ClassBody or +/// `defineProperty`), with `(enumerable, configurable)`. +pub(crate) fn class_static_own_accessor( + class_id: u32, + name: &str, +) -> Option<(crate::object::accessor_pair::Accessor, bool, bool)> { + use crate::object::key_attrs as ka; + let ptr = class_value_ptr(class_id) as usize; + // SAFETY: this agent's live class closure; its bag (if any) is a live + // ordinary object whose attributes live with its keys. Nothing allocates. + unsafe { + let bag = crate::closure::props::bag_of(ptr); + if bag.is_null() { + return None; + } + let entry = ka::object_key_entry(bag, name.as_bytes()); + if entry & ka::ENTRY_ACCESSOR == 0 { + return None; + } + let acc = crate::object::accessor_pair::own_accessor(bag as usize, name.as_bytes())?; + Some(( + acc, + entry & ka::ENTRY_NON_ENUMERABLE == 0, + entry & ka::ENTRY_NON_CONFIGURABLE == 0, + )) + } +} + +/// Does class `class_id` own an accessor property `name`? +pub(crate) fn class_static_has_own_accessor(class_id: u32, name: &str) -> bool { + class_static_own_accessor(class_id, name).is_some() +} + +/// Define (or replace) class `class_id`'s own accessor property `name`: a data +/// property of that name becomes this accessor. +pub(crate) fn class_static_define_accessor( + class_id: u32, + name: &str, + acc: crate::object::accessor_pair::Accessor, + enumerable: bool, + configurable: bool, +) { + let _no_collect = crate::gc::GcSuppressScope::new(); + let ptr = class_value_ptr(class_id) as usize; + // SAFETY: this agent's live class closure; no collection in this scope. + let bag = unsafe { crate::closure::props::bag_ensure(ptr) }; + crate::object::set_builtin_accessor_pair( + bag as usize, + name.to_string(), + acc, + crate::object::PropertyAttrs::new(false, enumerable, configurable), + ); +} + +/// Change the attributes of class `class_id`'s own accessor `name`. +pub(crate) fn class_static_set_accessor_attrs( + class_id: u32, + name: &str, + enumerable: bool, + configurable: bool, +) { + if let Some((acc, _, _)) = class_static_own_accessor(class_id, name) { + class_static_define_accessor(class_id, name, acc, enumerable, configurable); + } +} + +/// Class `class_id`'s own accessor property names, in creation order. +pub(crate) fn class_static_accessor_names(class_id: u32) -> Vec { + let ptr = class_value_ptr(class_id) as usize; + // SAFETY: this agent's live class closure. + unsafe { crate::closure::props::bag_accessor_names(ptr) } +} + +/// Run a class static accessor's getter for `receiver`. The compiled +/// ClassBody entry takes the static convention: `this` is armed (the class +/// the read started from — a stashed override — or `receiver`) and the +/// private/capture owner is `receiver`, the evaluation the getter was found +/// through (#10891/#10893). A `defineProperty` getter is an ordinary closure. +/// +/// # Safety +/// `acc` came from [`class_static_own_accessor`]. +pub(crate) unsafe fn class_static_accessor_call_get( + acc: crate::object::accessor_pair::Accessor, + receiver: f64, +) -> f64 { + let this = crate::object::field_get_set::accessor_receiver_override_take().unwrap_or(receiver); + if acc.raw_get != 0 { + crate::object::static_this_arm_if_unarmed(this); + crate::object::static_private_owner_push(receiver); + let f: extern "C" fn() -> f64 = std::mem::transmute(acc.raw_get); + let result = f(); + crate::object::static_private_owner_pop(); + crate::object::static_this_disarm(); + return result; + } + if acc.get != 0 { + return f64::from_bits(crate::object::invoke_accessor_getter(acc.get, this).bits()); + } + f64::from_bits(crate::value::TAG_UNDEFINED) +} + +/// Run a class static accessor's setter; `false` when the accessor has none. +/// +/// # Safety +/// As [`class_static_accessor_call_get`]. +pub(crate) unsafe fn class_static_accessor_call_set( + acc: crate::object::accessor_pair::Accessor, + receiver: f64, + value: f64, +) -> bool { + if acc.raw_set != 0 { + 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(acc.raw_set); + let _ = f(value); + crate::object::static_private_owner_pop(); + crate::object::static_this_disarm(); + return true; + } + if acc.set != 0 { + crate::object::invoke_accessor_setter(acc.set, receiver, value); + return true; + } + false +} + +/// `Object.getOwnPropertyDescriptor(C, name)` for an own accessor of the class. +pub(crate) fn class_static_accessor_descriptor(class_id: u32, name: &str) -> Option { + let (acc, enumerable, configurable) = class_static_own_accessor(class_id, name)?; + let undef = crate::value::TAG_UNDEFINED; + // SAFETY: both halves are the property's own closure values (or + // undefined); the builder roots them across its allocation. + Some(unsafe { + crate::object::descriptors::build_accessor_descriptor( + f64::from_bits(if acc.get == 0 { undef } else { acc.get }), + f64::from_bits(if acc.set == 0 { undef } else { acc.set }), + enumerable, + configurable, + ) + }) +} + // --------------------------------------------------------------------------- // Statics: the class function object's OWN properties. // --------------------------------------------------------------------------- @@ -609,6 +844,44 @@ mod tests { assert_eq!(class_value_id(other), Some(0x6A02)); } + /// A static walk that reaches a BUILTIN parent (`class E extends Error`) + /// stops there: a builtin id has no class function object, so no reader + /// may mint one for it (minting `0xFFFF_0001` grew the class-value + /// directory to 16M pages, which every collection then scanned). + #[test] + fn a_builtin_parent_never_gets_a_class_function_object() { + let cid = 0x6D71; + register(cid); + crate::object::js_register_class_parent(cid, crate::error::CLASS_ID_ERROR); + let recv = class_value(cid); + let (_, pages_before) = CLASS_VALUES.with(std::cell::Cell::get); + let applied = unsafe { + crate::object::class_registry::class_static_accessor_setter_apply(cid, "zz", recv, 1.0) + }; + assert!(!applied, "no static accessor named zz on the chain"); + assert!(!crate::object::class_registry::static_accessor_in_chain( + cid, "zz" + )); + // A static symbol read up the chain (latch armed, as once any class + // has a static symbol member) reaches Error too and must not mint. + crate::symbol::CLASS_STATIC_SYMBOLS_LATCH.arm(); + let sym = unsafe { crate::symbol::js_symbol_new_empty() }; + assert!(crate::symbol::class_static_symbol_lookup_in_chain(cid, sym).is_none()); + assert!( + crate::symbol::class_static_symbol_keys_for_class(crate::error::CLASS_ID_ERROR) + .is_empty() + ); + assert!( + class_value_cached(crate::error::CLASS_ID_ERROR).is_none(), + "the builtin Error id must not get a class function object" + ); + let (_, pages_after) = CLASS_VALUES.with(std::cell::Cell::get); + assert_eq!( + pages_after, pages_before, + "the walks grew the class-value directory" + ); + } + /// The table is a root: the scan visits every minted class value. #[test] fn class_value_table_is_scanned() { @@ -709,6 +982,50 @@ mod tests { ); } + /// A ClassBody static accessor is an accessor property of the class + /// function object: ClassBody attributes, one closure per half across + /// reads, attributes changed in place, and a delete removes it. + #[test] + fn static_accessors_are_accessor_properties_of_the_function_object() { + let cid = 0x6E01; + register(cid); + extern "C" fn getter() -> f64 { + 41.0 + } + unsafe { + crate::object::js_register_class_name(cid, b"Acc".as_ptr(), 3); + crate::object::class_registry::js_register_class_static_getter( + cid as i64, + b"g".as_ptr(), + 1, + getter as *const () as usize as i64, + ); + } + let (acc, enumerable, configurable) = + class_static_own_accessor(cid, "g").expect("an own accessor property"); + assert_ne!(acc.get, 0, "a reflected getter closure"); + assert_eq!(acc.set, 0); + assert_eq!((enumerable, configurable), CLASS_ACCESSOR_DEFAULT_ATTRS); + let again = class_static_own_accessor(cid, "g").unwrap().0; + assert_eq!(again.get, acc.get, "one closure per half"); + let ptr = class_value_ptr(cid) as usize; + assert_eq!( + unsafe { crate::closure::props::bag_get(ptr, b"g") }, + None, + "an accessor key has no data value" + ); + let got = unsafe { class_static_accessor_call_get(acc, class_value(cid)) }; + assert_eq!(got, 41.0); + class_static_set_accessor_attrs(cid, "g", true, false); + assert_eq!( + class_static_own_accessor(cid, "g").map(|(_, e, c)| (e, c)), + Some((true, false)) + ); + class_static_set_accessor_attrs(cid, "g", false, true); + assert!(class_static_remove(cid, "g"), "delete removes the property"); + assert!(class_static_own_accessor(cid, "g").is_none()); + } + /// Only the function object's own code pointer names a class. #[test] fn ordinary_closures_and_numbers_are_not_class_values() { diff --git a/crates/perry-runtime/src/object/delete_rest.rs b/crates/perry-runtime/src/object/delete_rest.rs index 58c073f261..dc24257bcf 100644 --- a/crates/perry-runtime/src/object/delete_rest.rs +++ b/crates/perry-runtime/src/object/delete_rest.rs @@ -69,18 +69,10 @@ pub extern "C" fn js_object_delete_field( unsafe { if let Some(name) = super::has_own_helpers::str_from_string_header(key) { let class_id = obj as usize as u32; - if super::class_registry::class_name_for_id(class_id).is_some() { - if super::class_registry::static_declared_accessor_ptrs(class_id, name) - .is_some() - && !super::class_registry::static_accessor_attrs(class_id, name).1 - { - return 0; - } - super::class_registry::class_delete_own_dynamic_prop(class_id, name); - super::class_registry::class_mark_key_deleted(class_id, name); - super::class_registry::invalidate_class_string_member_order( - class_id, name, true, - ); + if super::class_registry::class_name_for_id(class_id).is_some() + && class_delete_own_key(class_id, name) == 0 + { + return 0; } // #6363: a native HANDLE's own properties are its user expandos. // `delete` used to unconditionally report success while LEAVING @@ -204,6 +196,10 @@ pub extern "C" fn js_object_delete_field( // user-attached props are dropped from the dynamic-prop table outright. if crate::closure::is_closure_ptr(obj as usize) { if let Some(name) = super::has_own_helpers::str_from_string_header(key) { + // A class constructor: [[Delete]] on its own property. + if let Some(class_id) = crate::object::class_value::class_closure_id(obj as usize) { + return class_delete_own_key(class_id, name); + } // A plain (non-arrow, non-bound) function's `prototype` is a // non-configurable own property. `get_property_attrs` only knows // about it once #3655 has lazily registered a descriptor (on first @@ -793,6 +789,20 @@ fn delete_receiver_is_pointer(obj_value: f64) -> bool { crate::value::JSValue::from_bits(obj_value.to_bits()).is_pointer() } +/// `[[Delete]]` of class `class_id`'s own string key `name` (the class +/// constructor's own property): `0` when it is non-configurable. +fn class_delete_own_key(class_id: u32, name: &str) -> i32 { + if crate::object::class_value::class_static_own_accessor(class_id, name) + .is_some_and(|(_, _, configurable)| !configurable) + { + return 0; + } + super::class_registry::class_delete_own_dynamic_prop(class_id, name); + super::class_registry::class_mark_key_deleted(class_id, name); + super::class_registry::invalidate_class_string_member_order(class_id, name, true); + 1 +} + fn delete_class_prototype_key(class_id: u32, name: &str) -> i32 { if let Some(proto) = super::class_registry::decl_prototype_own_accessor(class_id, name) { // S2: the accessor is a real property of the declared prototype diff --git a/crates/perry-runtime/src/object/descriptors.rs b/crates/perry-runtime/src/object/descriptors.rs index d408b2f536..68409e3918 100644 --- a/crates/perry-runtime/src/object/descriptors.rs +++ b/crates/perry-runtime/src/object/descriptors.rs @@ -523,15 +523,13 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu { return js_object_get_own_property_descriptor(proto, key_value); } - } else if let Some((g, s)) = - super::class_registry::class_own_static_accessor_ptrs(class_id, &method_name) - { - return super::class_registry::static_accessor_descriptor( + } else if let Some(desc) = + crate::object::class_value::class_static_accessor_descriptor( class_id, &method_name, - g, - s, - ); + ) + { + return desc; } if super::class_prototype_ref_id(obj_value).is_some() && (method_name == "constructor" @@ -1189,6 +1187,10 @@ fn js_object_get_own_property_names_shape(obj_value: f64) -> f64 { for name in super::class_registry::class_own_dynamic_prop_names(class_id) { push_unique_name(&mut names, name); } + // Accessor properties a `defineProperty` added. + for name in crate::object::class_value::class_static_accessor_names(class_id) { + push_unique_name(&mut names, name); + } } names.retain(|n| { !super::field_get_set::is_internal_runtime_key(n) diff --git a/crates/perry-runtime/src/object/field_get_set/enumeration.rs b/crates/perry-runtime/src/object/field_get_set/enumeration.rs index dd3f783c53..0b6bf6e98f 100644 --- a/crates/perry-runtime/src/object/field_get_set/enumeration.rs +++ b/crates/perry-runtime/src/object/field_get_set/enumeration.rs @@ -171,8 +171,14 @@ pub extern "C" fn js_object_keys_value(value: f64) -> *mut ArrayHeader { if super::super::class_prototype_ref_id(value).is_none() { // Static accessors are defined before static fields, so an // enumerable one (#10480) precedes them. - let mut names = - super::super::class_registry::static_enumerable_accessor_names(class_id); + let mut names: Vec = + crate::object::class_value::class_static_accessor_names(class_id) + .into_iter() + .filter(|name| { + crate::object::class_value::class_static_own_accessor(class_id, name) + .is_some_and(|(_, enumerable, _)| enumerable) + }) + .collect(); names.extend(super::super::class_registry::class_own_enumerable_field_names(class_id)); super::super::descriptors::sort_property_names_ecma(&mut names); let arr = crate::array::js_array_alloc(names.len().max(1) as u32); diff --git a/crates/perry-runtime/src/object/field_get_set/has_property.rs b/crates/perry-runtime/src/object/field_get_set/has_property.rs index eb0e59a990..53b6638d3e 100644 --- a/crates/perry-runtime/src/object/field_get_set/has_property.rs +++ b/crates/perry-runtime/src/object/field_get_set/has_property.rs @@ -453,10 +453,9 @@ pub extern "C" fn js_object_has_property(obj: f64, key: f64) -> f64 { class_id, name, ) .is_some() - || super::super::class_registry::class_own_static_accessor_ptrs( + || super::super::class_registry::static_accessor_in_chain( class_id, name, ) - .is_some() || inherited_data)); if present { return nanbox_true; diff --git a/crates/perry-runtime/src/object/object_ops/define_class_accessor.rs b/crates/perry-runtime/src/object/object_ops/define_class_accessor.rs index 2f4c73251f..36f1058f38 100644 --- a/crates/perry-runtime/src/object/object_ops/define_class_accessor.rs +++ b/crates/perry-runtime/src/object/object_ops/define_class_accessor.rs @@ -3,9 +3,9 @@ //! //! An instance accessor is a real accessor property of the class's decl //! prototype, so a define through the prototype ref is the ordinary define on -//! that object. A static accessor is an own property of the constructor `C`, -//! which is a ClassRef value, not an object: its get/set live in -//! `CLASS_STATIC_ACCESSORS` and its attributes in `static_accessor_attrs.rs`. +//! that object. A static accessor is an accessor property of the class +//! function object's own-property object (`object::class_value`), attributes +//! with its key. use super::*; /// ValidateAndApplyPropertyDescriptor for the declared accessor `name` of @@ -43,32 +43,25 @@ pub(super) unsafe fn define_declared_class_accessor( super::js_object_define_property(proto.get_nanbox_f64(), key, desc.get_nanbox_f64()); return true; } - let Some((getter, setter)) = - super::super::class_registry::static_declared_accessor_ptrs(class_id, name) + let Some((acc, enumerable, configurable)) = + crate::object::class_value::class_static_own_accessor(class_id, name) else { return false; }; - let (enumerable, configurable) = - super::super::class_registry::static_accessor_attrs(class_id, name); // The per-field reads below allocate a field-name string (and may run a // user getter on a non-plain descriptor), so the descriptor is re-read from // its root at every use. let scope = crate::gc::RuntimeHandleScope::new(); let desc = scope.root_nanbox_f64(descriptor_value); if !configurable { - // The validator compares accessor halves by closure `func_ptr`, which a - // reflected class accessor value carries. Root the getter value across - // the setter value's allocation; the validator roots both on entry. - let get = scope.root_nanbox_f64( - super::super::class_registry::class_accessor_function_value(getter, false, name), - ); - let set = super::super::class_registry::class_accessor_function_value(setter, true, name); + // The validator compares accessor halves by closure identity: the + // property's own closures. validate_nonconfigurable_redefine( name, PropertyAttrs::new(false, enumerable, false), Some(AccessorDescriptor { - get: get.get_nanbox_u64(), - set: set.to_bits(), + get: acc.get, + set: acc.set, }), f64::from_bits(crate::value::TAG_UNDEFINED), desc.get_nanbox_f64(), @@ -102,7 +95,7 @@ pub(super) unsafe fn define_declared_class_accessor( }; let enumerable = flag(DESC_ENUMERABLE, b"enumerable").unwrap_or(enumerable); let configurable = flag(DESC_CONFIGURABLE, b"configurable").unwrap_or(configurable); - super::super::class_registry::set_static_accessor_attrs( + crate::object::class_value::class_static_set_accessor_attrs( class_id, name, enumerable, diff --git a/crates/perry-runtime/src/object/object_ops/has_own.rs b/crates/perry-runtime/src/object/object_ops/has_own.rs index 3d355e0f84..3aeed7440d 100644 --- a/crates/perry-runtime/src/object/object_ops/has_own.rs +++ b/crates/perry-runtime/src/object/object_ops/has_own.rs @@ -241,10 +241,9 @@ pub extern "C" fn js_object_has_own(obj_value: f64, key_value: f64) -> f64 { class_id, key, ) .is_some() - || super::super::class_registry::class_own_static_accessor_ptrs( + || crate::object::class_value::class_static_has_own_accessor( class_id, key, - ) - .is_some())) + ))) } }) .unwrap_or(false); @@ -600,15 +599,8 @@ pub extern "C" fn js_object_property_is_enumerable(obj_value: f64, key_value: f6 // ClassBody default, but a generic descriptor can flip it // (Object.defineProperty(C, "x", { enumerable: true })). let is_enumerable_static_accessor = - super::super::class_registry::static_accessor_attrs_in_use() - && super::super::class_registry::static_declared_accessor_ptrs( - class_id, key_name, - ) - .is_some() - && super::super::class_registry::static_accessor_attrs( - class_id, key_name, - ) - .0; + crate::object::class_value::class_static_own_accessor(class_id, key_name) + .is_some_and(|(_, enumerable, _)| enumerable); return f64::from_bits(if is_static_field || is_enumerable_static_accessor { TAG_TRUE } else { diff --git a/crates/perry-runtime/src/object/property_key.rs b/crates/perry-runtime/src/object/property_key.rs index da68b8c1c3..63036a0e4a 100644 --- a/crates/perry-runtime/src/object/property_key.rs +++ b/crates/perry-runtime/src/object/property_key.rs @@ -362,32 +362,29 @@ pub unsafe extern "C" fn js_super_accessor_get( // class/super/in-static-{getter,methods,setter}. if super::class_ref_id(receiver).is_some() { if let Some(key_name) = key_name.as_ref() { - // (a) parent static getter, walking the class_id chain. - if let Ok(guard) = crate::object::CLASS_STATIC_ACCESSORS.read() { - if let Some(reg) = guard.as_ref() { - let mut cid = parent_class_id; - let mut depth = 0usize; - while cid != 0 && depth < 32 { - if let Some(getter_ptr) = - reg.get(&cid).and_then(|m| m.get(key_name)).map(|&(g, _)| g) + // (a) the parent's static accessor (an accessor property of its + // class function object), walking the class_id chain. + { + let mut cid = parent_class_id; + let mut depth = 0usize; + // Only a compiled class has a function object: a builtin parent + // (`extends Error`) ends the walk. + while cid != 0 && depth < 32 && crate::object::is_class_id_registered(cid) { + if let Some((acc, _, _)) = + crate::object::class_value::class_static_own_accessor(cid, key_name) + { + return crate::object::class_value::class_static_accessor_call_get( + acc, receiver, + ); + } + match crate::object::get_parent_class_id(cid) { + Some(p) + if p != 0 && p != cid && crate::object::is_class_id_registered(p) => { - if getter_ptr != 0 { - let f: extern "C" fn(f64) -> f64 = std::mem::transmute(getter_ptr); - let this_scope = crate::gc::RuntimeHandleScope::new(); // #9445 - let prev = this_scope - .root_nanbox_f64(crate::object::js_implicit_this_set(receiver)); - let r = f(receiver); - crate::object::js_implicit_this_set(prev.get_nanbox_f64()); - return r; - } - } - match crate::object::get_parent_class_id(cid) { - Some(p) if p != 0 && p != cid => { - cid = p; - depth += 1; - } - _ => break, + cid = p; + depth += 1; } + _ => break, } } } diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index 555e8c7f57..8ec725d66d 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -1585,6 +1585,25 @@ fn own_set_descriptor(target: f64, key: f64) -> Option { // allocation. Closures don't carry the flag, so keep consulting the side // tables for them (their `name`/`length` + user `defineProperty` descriptors // live there). + // A class function object's static accessor is an accessor property of + // its own-property object (#11521: a getter-only one refuses the write). + // Its own data properties live in the same object; anything else is not + // own, and the walk continues at the class's [[Prototype]]. + if let Some(class_id) = crate::object::class_value::class_closure_id(obj_ptr) { + if let Some((acc, _, _)) = + crate::object::class_value::class_static_own_accessor(class_id, &key_name) + { + return Some(OwnSetDescriptor::Accessor { + setter_bits: acc.set, + }); + } + if crate::object::class_value::class_static_get(class_id, &key_name).is_some() { + let writable = crate::object::class_static_defined_attrs(class_id, &key_name) + .is_none_or(|(writable, _, _)| writable); + return Some(OwnSetDescriptor::Data { writable }); + } + return None; + } if crate::object::object_has_descriptors(obj_ptr) || crate::closure::is_closure_ptr(obj_ptr) { if let Some(acc) = crate::object::get_accessor_descriptor(obj_ptr, &key_name) { return Some(OwnSetDescriptor::Accessor { @@ -1659,6 +1678,13 @@ fn prototype_of_for_set(value: f64) -> Option { // `is_valid_obj_ptr(obj)` -- a magnitude-only check whose own floor // is 0x1000 -- followed by an unconditional `(*obj).class_id` read, // so an admitted handle id reached that deref. + // A class function object is a closure, not an ObjectHeader: its + // [[Prototype]] is the class's (the parent class for `extends`). + if let Some(class_id) = crate::object::class_value::class_closure_id(raw) { + let proto = crate::object::class_value::class_prototype_addr(class_id); + return (proto != 0 && proto != raw) + .then(|| f64::from_bits(POINTER_TAG | proto as u64)); + } if crate::value::addr_class::is_above_handle_band(raw) { if let Some(proto_bits) = crate::object::prototype_chain::object_static_prototype(raw) { if proto_bits == TAG_NULL || proto_bits == TAG_UNDEFINED || proto_bits == bits { @@ -2286,7 +2312,14 @@ fn ordinary_set_with_receiver(target: f64, key: f64, value: f64, receiver: f64) legacy_dunder_proto_set(receiver, value); return true; } - if crate::closure::is_closure_ptr(extract_pointer(current.to_bits()) as usize) { + // A class function object is not a leaf of the walk: its [[Prototype]] + // (the parent class) may hold the accessor (#11521). + if crate::closure::is_closure_ptr(extract_pointer(current.to_bits()) as usize) + && crate::object::class_value::class_closure_id( + extract_pointer(current.to_bits()) as usize + ) + .is_none() + { // ECMAScript poison pill: `fn.caller = v` / `fn.arguments = v` on // a strict-mode function throws via %ThrowTypeError%. A plain // non-strict function instead rejects the inherited setter-less @@ -2370,6 +2403,11 @@ fn class_link_accessor_set(current: f64, key: f64, value: f64, receiver: f64) -> if link.is_null() || crate::object::js_object_get_class_id(recv) != 0 { return None; } + // A class function object is a closure (no ObjectHeader class id): its + // static accessors are own properties `own_set_descriptor` reports. + if crate::object::class_value::class_closure_id(link as usize).is_some() { + return None; + } let class_id = crate::object::js_object_get_class_id(link); if class_id == 0 || class_id == crate::object::NATIVE_MODULE_CLASS_ID diff --git a/crates/perry-runtime/src/symbol/properties.rs b/crates/perry-runtime/src/symbol/properties.rs index 5724c6a455..332e6edea4 100644 --- a/crates/perry-runtime/src/symbol/properties.rs +++ b/crates/perry-runtime/src/symbol/properties.rs @@ -633,7 +633,7 @@ fn class_static_symbol_lookup_slow(class_id: u32, sym_f64: f64) -> Option { if class_id == 0 || sym_key == 0 { return None; } - let owner = crate::object::class_value::class_value_ptr(class_id) as usize; + let owner = crate::object::class_value::class_value_if_minted(class_id)? as usize; symbol_property_root_bits(owner, sym_key) } @@ -664,8 +664,10 @@ pub(crate) fn class_static_symbol_keys_for_class(class_id: u32) -> Vec { if class_id == 0 { return Vec::new(); } - let owner = crate::object::class_value::class_value_ptr(class_id) as usize; - clone_symbol_entries_for_obj_ptr(owner) + let Some(owner) = crate::object::class_value::class_value_if_minted(class_id) else { + return Vec::new(); + }; + clone_symbol_entries_for_obj_ptr(owner as usize) .into_iter() .map(|(sym_key, _)| sym_key) .collect() diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 6d2851c10d..cb2b38c709 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -860,12 +860,6 @@ "verdict": "test_only", "why": "#[cfg(test)] Cell counting the inherited-access chain-verdict hits served on the calling test thread, so a test can assert its own site fired while others run beside it. It stores only a count and is absent from shipped binaries." }, - { - "file": "crates/perry-runtime/src/object/class_registry/static_accessor_attrs.rs", - "name": "STATIC_ACCESSOR_ATTRS", - "verdict": "not_a_gc_pointer", - "why": "Reflective enumerable/configurable overrides applied by a generic Object.defineProperty(/ies) descriptor to a DECLARED STATIC class accessor (#10480), keyed by (class_id: u32, name: String) to (enumerable: bool, configurable: bool). Every field is a plain scalar or an owned String \u2014 no NaN-boxed JSValue, no heap ObjectHeader address, nothing for the collector to mark or rewrite. The static accessor's getter/setter function pointers stay in CLASS_STATIC_ACCESSORS; this table only remembers which two attribute bits a generic descriptor overrode. Instance accessors are real accessor properties of the class prototype and have no entry here." - }, { "file": "crates/perry-runtime/src/object/class_registry/state.rs", "name": "CLASS_DECLARED_STATIC_GLOBAL_SLOTS", diff --git a/scripts/registry_lifetime_allowlist.json b/scripts/registry_lifetime_allowlist.json index ad6345431b..459574f9ce 100644 --- a/scripts/registry_lifetime_allowlist.json +++ b/scripts/registry_lifetime_allowlist.json @@ -391,12 +391,6 @@ "verdict": "open_leak", "why": "Key includes the runtime SymbolHeader.id: a re-evaluated class expression with computed [Symbol()] members adds entries each time" }, - { - "file": "crates/perry-runtime/src/object/class_registry/static_accessor_attrs.rs", - "name": "STATIC_ACCESSOR_ATTRS", - "verdict": "bounded_by_program", - "why": "(class_id, declared static accessor name) -> attrs, set only on existing declared accessors" - }, { "file": "crates/perry-runtime/src/object/class_registry/verdict_classes.rs", "name": "VERDICT_CLASSES", diff --git a/test-files/test_gap_class_static_accessor_props.ts b/test-files/test_gap_class_static_accessor_props.ts new file mode 100644 index 0000000000..2a91c85775 --- /dev/null +++ b/test-files/test_gap_class_static_accessor_props.ts @@ -0,0 +1,44 @@ +// Static class accessors are accessor properties of the class constructor: +// reflection, attributes, delete, inheritance and [[Set]] all see one property. +class A { + static count = 0; + static get x() { return "x:" + (this as any).name; } + static set x(v: string) { A.count++; } + static get ro() { return 1; } +} +class B extends A {} +console.log(A.x, B.x, (B as any).ro); +const d = Object.getOwnPropertyDescriptor(A, "x")!; +console.log(typeof d.get, typeof d.set, d.enumerable, d.configurable); +console.log(d.get === Object.getOwnPropertyDescriptor(A, "x")!.get); +console.log(Object.getOwnPropertyNames(A).join(","), Object.keys(A).join(",")); +(A as any).x = "v"; +(B as any).x = "w"; +console.log(A.count); +// #11521: a getter-only static refuses the write. +try { + (A as any).ro = 5; + console.log("accepted", (A as any).ro); +} catch (e) { + console.log("TypeError", e instanceof TypeError, (A as any).ro); +} +try { + (B as any).ro = 5; + console.log("accepted", (B as any).ro); +} catch (e) { + console.log("TypeError", e instanceof TypeError); +} +Object.defineProperty(A, "x", { enumerable: true }); +console.log(Object.keys(A).join(","), A.propertyIsEnumerable("x"), A.x); +Object.defineProperty(A, "dyn", { get() { return "dyn"; }, set(v) {}, configurable: true, enumerable: false }); +console.log((A as any).dyn, (B as any).dyn, Object.getOwnPropertyNames(A).includes("dyn"), "dyn" in B); +console.log(delete (A as any).dyn, (A as any).dyn, "dyn" in A); +console.log(delete (A as any).x, A.x, B.x, "x" in A, Object.getOwnPropertyDescriptor(A, "x")); +class C { + static get v() { return 1; } +} +Object.defineProperty(C, "v", { configurable: false }); +console.log(Reflect.deleteProperty(C, "v"), C.v, Object.getOwnPropertyDescriptor(C, "v")!.configurable); +Object.defineProperty(C, "w", { value: 7, configurable: true }); +Object.defineProperty(C, "w", { get() { return 8; } }); +console.log((C as any).w, typeof Object.getOwnPropertyDescriptor(C, "w")!.get); diff --git a/test-files/test_gap_class_static_accessor_reflect.ts b/test-files/test_gap_class_static_accessor_reflect.ts new file mode 100644 index 0000000000..a1000c393e --- /dev/null +++ b/test-files/test_gap_class_static_accessor_reflect.ts @@ -0,0 +1,18 @@ +// Static class accessors are real accessor properties of the constructor; +// C.prototype's own names are its real own keys. +class C { static get a() { return 1; } static set a(v: number) {} static get k() { return 3; } } +console.log(Reflect.deleteProperty(C, "a"), "a" in C, Object.getOwnPropertyDescriptor(C, "a") === undefined); +Object.defineProperty(C, "b", { get() { return 2; }, configurable: false }); +console.log(Reflect.deleteProperty(C, "b"), (C as any).b, Object.getOwnPropertyDescriptor(C, "b")!.configurable); +Object.defineProperty(C, "k", { configurable: false }); +console.log(Reflect.deleteProperty(C, "k"), (C as any).k); +class D { m() {} get g() { return 1; } set g(v) {} static s() {} static get sg() { return 1; } static f = 1; x = 1; } +console.log(Object.getOwnPropertyNames(D.prototype).sort().join(",")); +delete (D.prototype as any).m; +console.log(Object.getOwnPropertyNames(D.prototype).sort().join(","), "m" in new D()); +(D.prototype as any).added = 1; +console.log(Object.getOwnPropertyNames(D.prototype).sort().join(",")); +delete (D.prototype as any).g; +console.log(Object.getOwnPropertyNames(D.prototype).sort().join(","), Reflect.ownKeys(D.prototype).length); +class E extends D { n() {} } +console.log(Object.getOwnPropertyNames(E.prototype).sort().join(","), Object.getOwnPropertyNames(Object.getPrototypeOf(E.prototype)).sort().join(",")); diff --git a/test-files/test_gap_class_static_getter_only_set.ts b/test-files/test_gap_class_static_getter_only_set.ts new file mode 100644 index 0000000000..83ae7e49b5 --- /dev/null +++ b/test-files/test_gap_class_static_getter_only_set.ts @@ -0,0 +1,31 @@ +// #11521: a write to a getter-only static accessor is rejected. Strict +// [[Set]] (module code) throws a TypeError; Reflect.set reports false. The +// value is unchanged either way, on the class and on a subclass. +class Cfg { + static get version() { return 3; } + static get label() { return "cfg:" + (this as any).name; } + static set label(_v: string) { Cfg.writes++; } + static writes = 0; +} +class Sub extends Cfg {} + +function attempt(what: string, fn: () => void) { + try { + fn(); + console.log(what, "accepted"); + } catch (e) { + console.log(what, (e as Error).constructor.name, e instanceof TypeError); + } +} + +attempt("Cfg.version", () => { (Cfg as any).version = 4; }); +attempt("Sub.version", () => { (Sub as any).version = 4; }); +attempt("Cfg[k]", () => { const k = "version"; (Cfg as any)[k] = 5; }); +console.log(Cfg.version, (Sub as any).version); +console.log(Reflect.set(Cfg, "version", 6), Reflect.set(Sub, "version", 6), Cfg.version); +// A setter half accepts the write (and runs), on the class and a subclass. +attempt("Cfg.label", () => { (Cfg as any).label = "x"; }); +attempt("Sub.label", () => { (Sub as any).label = "y"; }); +console.log(Reflect.set(Cfg, "label", "z"), Cfg.writes, Cfg.label, (Sub as any).label); +// The getter-only refusal does not create an own data property. +console.log(Object.getOwnPropertyNames(Sub).includes("version"), typeof Object.getOwnPropertyDescriptor(Cfg, "version")!.get); From fe05e88b56eb5ced0e6bbe03380d620b7d2a4b67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 23:04:01 +0200 Subject: [PATCH 24/29] changelog: name the fragment after PR #11651 --- ...or-properties.md => 11651-class-static-accessor-properties.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{class-static-accessor-properties.md => 11651-class-static-accessor-properties.md} (100%) diff --git a/changelog.d/class-static-accessor-properties.md b/changelog.d/11651-class-static-accessor-properties.md similarity index 100% rename from changelog.d/class-static-accessor-properties.md rename to changelog.d/11651-class-static-accessor-properties.md From 4eb457f34a5edb9cf08f83220281670cc04b20f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 21:22:06 +0000 Subject: [PATCH 25/29] runtime: trace a class function object's statics bag from the class-value root A class function object is born old and PINNED, and marking never queues a pinned header ("pinned objects are always live"), so no collector ever enumerated its child slots from its root in CLASS_VALUES. Minors reached the own-property bag through the remembered set the bag_ensure barrier dirtied, but a full trace never visited the bag: the shape the bag carries was never noted as carried, post-trace descriptor retirement dropped it, and every static of the class read back as absent (the forced-evacuation class-static-computed-field case of the #6943 suite). The class-value root scan now visits the `props` edge of each class function object as a root slot of its own: a full trace marks and traces the bag, and a moving collection rewrites the edge. No new table. Regression: class_value::tests::a_full_collection_keeps_the_class_statics_bag (red without the edge visit). --- .../perry-runtime/src/object/class_value.rs | 75 ++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/crates/perry-runtime/src/object/class_value.rs b/crates/perry-runtime/src/object/class_value.rs index ea584c15ec..c8872d2eef 100644 --- a/crates/perry-runtime/src/object/class_value.rs +++ b/crates/perry-runtime/src/object/class_value.rs @@ -381,6 +381,16 @@ pub extern "C" fn js_class_value(class_id: i32) -> f64 { /// GC root scan for [`CLASS_VALUES`]; registered in `gc::mod`'s runtime /// scanner list. +/// +/// A class function object is PINNED, and marking never queues a pinned +/// header (`try_mark_*`: "pinned objects are always live"), so no collector +/// enumerates its child slots from a root. Its one heap edge, the own-property +/// bag (`props`, the statics), is therefore visited here as a root slot of its +/// own: a full trace marks and traces the bag (and notes the shape it carries, +/// which post-trace descriptor retirement reads), and a moving collection +/// rewrites the edge. A minor also reaches the edge through the remembered set +/// the `bag_ensure` store barrier dirtied; the second visit of a rewritten +/// slot sees the forwarded address and is a no-op. pub(crate) fn scan_class_value_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { let (pages, len) = CLASS_VALUES.with(std::cell::Cell::get); for i in 0..len { @@ -391,8 +401,14 @@ pub(crate) fn scan_class_value_roots_mut(visitor: &mut crate::gc::RuntimeRootVis } // SAFETY: a live leaked page of this agent. for slot in unsafe { (*page).iter_mut() } { - if !slot.is_null() { - visitor.visit_raw_mut_ptr_slot(slot); + if slot.is_null() { + continue; + } + visitor.visit_raw_mut_ptr_slot(slot); + // SAFETY: a live class function object of this agent. + let props = unsafe { &mut (**slot).props }; + if !props.is_null() { + visitor.visit_raw_mut_ptr_slot(props); } } } @@ -627,6 +643,61 @@ mod tests { assert!(seen, "the class-value table must be a GC root"); } + /// #11609: the class function object is pinned, and marking never queues a + /// pinned header, so its own-property bag (the statics) is reached only + /// because the class-value root scan visits the `props` edge itself. + /// Without that, a full trace never visits the bag: the shape the bag + /// carries is never noted as carried, post-trace descriptor retirement + /// drops it, and every static reads back as absent. + #[test] + fn a_full_collection_keeps_the_class_statics_bag() { + let cid = 0x6B02; + register(cid); + // A unit-test thread may not have run `gc_init`'s scanner list. + crate::gc::gc_register_mutable_root_scanner(scan_class_value_roots_mut); + let ptr = class_value_ptr(cid) as usize; + let text = "static-payload-11609"; + let s = crate::string::js_string_from_bytes(text.as_ptr(), text.len() as u32); + class_static_set( + cid, + "k11609", + f64::from_bits(crate::value::JSValue::string_ptr(s).bits()), + ); + let mut saw_bag = false; + let bag = unsafe { crate::closure::props::bag_of(ptr) } as usize; + assert_ne!(bag, 0, "the static installed a bag"); + scan_class_value_roots_mut(&mut crate::gc::RuntimeRootVisitor::for_copy( + &mut |v: f64| { + let bits = v.to_bits(); + if bits as usize == bag || (bits & crate::value::POINTER_MASK) as usize == bag { + saw_bag = true; + } + }, + )); + assert!( + saw_bag, + "the root scan must visit the pinned class's bag edge" + ); + crate::gc::js_gc_collect(); + crate::gc::js_gc_collect(); + let got = class_static_get(cid, "k11609").expect("the static survives a full collection"); + let got = crate::value::JSValue::from_bits(got.to_bits()); + let hdr = got.as_string_ptr(); + assert!(!hdr.is_null()); + let bytes = unsafe { + std::slice::from_raw_parts( + (hdr as *const u8).add(std::mem::size_of::()), + (*hdr).byte_len as usize, + ) + }; + assert_eq!(bytes, text.as_bytes()); + let keys: Vec = class_static_entries(cid) + .into_iter() + .map(|(k, _)| k) + .collect(); + assert!(keys.iter().any(|k| k == "k11609"), "own keys: {keys:?}"); + } + /// The kind is a shape fact: class function objects carry their own /// ShapeId, distinct from FunctionDictionary (shapes are canonical per /// facts — without its marker fact the class shape WOULD be the dictionary From 19cbd0ccc0783fbd1ced6d3933d7d61c5de5277a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 22:00:19 +0000 Subject: [PATCH 26/29] runtime: move class-method binding out of native_module.rs native_module.rs was 2006 lines, over the 2000-line cap. The class-method binding block (js_class_method_bind, its snapshot and by-id forms, the test hooks, and the private-brand-aware builder they share) moves to native_module/class_method_bind.rs; native_module.rs re-exports it, so every path is unchanged. The LTO keepalive-anchor test reads the new file for KEEP_CLASS_METHOD_BIND_BY_ID. --- .../perry-runtime/src/object/native_module.rs | 329 +----------------- .../object/native_module/class_method_bind.rs | 328 +++++++++++++++++ .../perry-runtime/src/typed_feedback/tests.rs | 6 +- 3 files changed, 341 insertions(+), 322 deletions(-) create mode 100644 crates/perry-runtime/src/object/native_module/class_method_bind.rs diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index acdc714c81..6b6fd312db 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -1243,328 +1243,15 @@ pub extern "C" fn js_native_module_bind_method( bound_native_callable_export_value(&module_name, property_name) } -/// Build a "bound method" closure for `obj.method` PropertyGet on a known class -/// instance. The captures (instance, method_name_ptr, method_name_len) drive -/// `dispatch_bound_method` (closure.rs), which calls `js_native_call_method` -/// — that resolves the method through `CLASS_VTABLE_REGISTRY` for any class -/// registered by `js_register_class_method` at module init. -/// -/// Issue #446: previously a class method reference (`let f = obj.method`, -/// `typeof obj.method`, `arr.map(obj.method)`) silently lowered to the -/// generic property-bag lookup, which doesn't store prototype methods — -/// every such read returned `undefined`, so `typeof obj.method === "undefined"` -/// and a captured method ran no body when invoked. -/// -/// Method-name pointer is expected to be stable for the closure's lifetime; -/// codegen emits it from the per-module `.str.N.bytes` rodata global. -#[no_mangle] -pub extern "C" fn js_class_method_bind( - instance: f64, - method_name_ptr: *const u8, - method_name_len: usize, -) -> f64 { - if !method_name_ptr.is_null() && method_name_len > 0 { - if let Ok(name) = unsafe { - std::str::from_utf8(std::slice::from_raw_parts(method_name_ptr, method_name_len)) - } { - if matches!( - name, - "append" - | "delete" - | "entries" - | "forEach" - | "get" - | "getSetCookie" - | "has" - | "keys" - | "set" - | "Symbol.iterator" - | "@@iterator" - | "values" - ) { - let bits = instance.to_bits(); - if (bits >> 48) == 0x7FFD { - let id = (bits & 0x0000_FFFF_FFFF_FFFF) as i64; - if crate::value::addr_class::is_small_handle(id as usize) { - if let Some(dispatch) = handle_property_dispatch() { - let value = HANDLE_PROPERTY_BIND_REENTRY.with(|guard| { - if guard.get() { - None - } else { - guard.set(true); - let value = - unsafe { dispatch(id, method_name_ptr, method_name_len) }; - guard.set(false); - Some(value) - } - }); - if let Some(value) = value { - if value.to_bits() != crate::value::TAG_UNDEFINED { - return value; - } - } - } - } - } - } - } - } - - // Method IDENTITY (test262 class/elements): a class method is a single - // shared function object, so `c.m`, `c2.m` and `C.prototype.m` must all be - // the IDENTICAL value. Route every user-class method-as-value read through - // the per-`(owner_class, name)` cached canonical built by - // `class_prototype_method_value_for_name` instead of minting a fresh - // per-receiver closure here. The canonical captures the OWNER class's - // prototype-ref (capture 0); `dispatch_bound_method` recognises that marker - // and supplies the call-site `this` (IMPLICIT_THIS) so invocations still see - // the right receiver — e.g. the `this.m = this.m.bind(this)` idiom rebinds - // correctly, and a bare `const f = c.m; f()` runs with the spec `this`. - // - // Guard against re-entry from `class_prototype_method_value_for_name` - // itself: it builds the canonical by calling `build_bound_method_closure` - // directly (NOT this function), so the cache is populated without looping. - if !method_name_ptr.is_null() && method_name_len > 0 { - if let Ok(name) = unsafe { - std::str::from_utf8(std::slice::from_raw_parts(method_name_ptr, method_name_len)) - } { - // #7689: a CONSTRUCTOR class-ref receiver (`const f = C.m`) must - // never canonicalize to the INSTANCE vtable method of the same - // name — in JS `C.m` sees only statics (`class C { static lex(){} - // lex(){} }` has `C.lex` === the static; the instance `lex` lives - // on `C.prototype`). `class_id_from_method_receiver` treats a - // class ref like an instance, so marked's `const lexer2 = - // _Lexer.lex; lexer2(src, opt)` extracted the instance `lex`, - // whose bare invocation read `this.options` off an unconstructed - // receiver. Fall through to `build_bound_method_closure`: its - // call-time dispatch (`js_native_call_method`'s 0x7FFE arm) - // resolves statics-first for constructor refs. PROTOTYPE refs - // (`C.prototype.m`) keep the canonical path — the instance method - // is exactly what they name. - let receiver_class_ref = class_ref_id(instance); - let receiver_is_constructor_ref = - receiver_class_ref.is_some() && class_prototype_ref_id(instance).is_none(); - if !receiver_is_constructor_ref && bound_native_method_length(name).is_none() { - if let Some(class_id) = - class_id_from_method_receiver_known(instance, receiver_class_ref) - { - let private_owner = super::take_private_method_owner_hint(name); - if let Some(owner) = private_owner - .or_else(|| super::class_registry::method_owner_class_id(class_id, name)) - { - // [[Get]] order: an OWN data property of this name - // shadows the prototype method. The ubiquitous - // `this.m = this.m.bind(this)` idiom installs an own `m` - // (a bound function), so `obj.m` must read that own value - // back — not the shared prototype method. Skipping this - // both returned the wrong identity (`obj.m === - // C.prototype.m` where Node says false) and looped when - // the canonical re-resolved `m` by name. A class - // prototype-ref receiver has no own-property bag, so this - // check is naturally a no-op there. - let recv_jsv = JSValue::from_bits(instance.to_bits()); - if private_owner.is_none() - && recv_jsv.is_pointer() - && !super::class_registry::is_registered_class_prototype_object( - crate::value::js_nanbox_get_pointer(instance) as usize, - ) - { - let obj = recv_jsv.as_pointer::(); - if crate::value::addr_class::is_above_handle_band(obj as usize) { - let key = crate::string::js_string_from_bytes( - method_name_ptr, - method_name_len as u32, - ); - if let Some(own) = - unsafe { super::own_data_field_by_name(obj, key) } - { - if own.bits() != crate::value::TAG_UNDEFINED { - return f64::from_bits(own.bits()); - } - } - } - } - let lexical_owner = private_owner - .and_then(|owner| super::current_private_lexical_brand_value(owner)); - let canonical = lexical_owner - .or_else(|| private_evaluation_brand_value(instance)) - .map(|brand| class_evaluation_method_value_for_name(owner, name, brand)) - .unwrap_or_else(|| class_prototype_method_value_for_name(owner, name)); - if canonical.to_bits() != crate::value::TAG_UNDEFINED { - return canonical; - } - } - } - } - } - } - - build_bound_method_closure(instance, method_name_ptr, method_name_len) -} - -/// Perry's intentional `this.method` value-read contract: capture the instance -/// at read time so a later own-property replacement cannot change the method's -/// receiver or target. Ordinary `obj.method` reads still use -/// [`js_class_method_bind`] and its canonical per-class value identity. -/// -/// An own value that already exists wins at read time. This keeps constructor -/// arrow overrides (`this.m = () => ...; const f = this.m`) on the ordinary -/// property path instead of replacing them with a prototype-method snapshot. -#[no_mangle] -pub extern "C" fn js_class_method_snapshot_bind( - instance: f64, - method_name_ptr: *const u8, - method_name_len: usize, -) -> f64 { - let value = JSValue::from_bits(instance.to_bits()); - if !value.is_pointer() - || class_registry::is_class_object_value(instance) - || class_id_from_method_receiver(instance).is_none() - { - return js_class_method_bind(instance, method_name_ptr, method_name_len); - } - - let scope = crate::gc::RuntimeHandleScope::new(); - let instance_handle = scope.root_nanbox_f64(instance); - if !method_name_ptr.is_null() && method_name_len > 0 { - let key = crate::string::js_string_from_bytes(method_name_ptr, method_name_len as u32); - let key_handle = scope.root_string_ptr(key); - let current = instance_handle.get_nanbox_f64(); - let obj = JSValue::from_bits(current.to_bits()).as_pointer::(); - if crate::value::addr_class::is_above_handle_band(obj as usize) { - let own = key_handle.with_const_ptr::(|key| unsafe { - super::own_data_field_by_name(obj, key) - }); - if let Some(own) = own { - if own.bits() != crate::value::TAG_UNDEFINED { - return f64::from_bits(own.bits()); - } - } - } - } - - build_bound_method_closure( - instance_handle.get_nanbox_f64(), - method_name_ptr, - method_name_len, - ) -} - -/// By-ID sibling of `js_class_method_bind` for static-name lowering. -/// -/// Current codegen passes an immutable AOT descriptor. Legacy heap/short-string -/// ids remain accepted for ABI compatibility. -#[no_mangle] -pub extern "C" fn js_class_method_bind_by_id(instance: f64, method_id: i64) -> f64 { - let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; - let Some(name_ref) = crate::string::perry_string_ref_from_dispatch_id(method_id, &mut scratch) - else { - return f64::from_bits(crate::value::TAG_UNDEFINED); - }; - js_class_method_bind(instance, name_ref.ptr, name_ref.len) -} - -#[cfg(feature = "keepalive-anchors")] -#[used(compiler)] -static KEEP_CLASS_METHOD_BIND_BY_ID: extern "C" fn(f64, i64) -> f64 = js_class_method_bind_by_id; - -#[cfg(test)] -thread_local! { - static TEST_COLLECT_BOUND_METHOD_AFTER_CAPTURE_INIT: std::cell::Cell = - const { std::cell::Cell::new(false) }; - static TEST_BOUND_METHOD_MOVE: std::cell::Cell<(usize, usize)> = - const { std::cell::Cell::new((0, 0)) }; -} - -#[cfg(test)] -pub(crate) fn test_collect_bound_method_after_capture_init() { - TEST_COLLECT_BOUND_METHOD_AFTER_CAPTURE_INIT.with(|armed| armed.set(true)); - TEST_BOUND_METHOD_MOVE.with(|trace| trace.set((0, 0))); -} - +mod class_method_bind; +use class_method_bind::build_bound_method_closure_with_private_brand; +pub use class_method_bind::{ + js_class_method_bind, js_class_method_bind_by_id, js_class_method_snapshot_bind, +}; #[cfg(test)] -pub(crate) fn test_take_bound_method_move() -> (usize, usize) { - TEST_BOUND_METHOD_MOVE.with(|trace| trace.replace((0, 0))) -} - -fn build_bound_method_closure_with_private_brand( - instance: f64, - method_name_ptr: *const u8, - method_name_len: usize, - private_brand: Option, -) -> f64 { - // `js_closure_alloc` can collect before it returns, so keep the receiver - // live across that allocation. The metadata installation below allocates a - // string for `.name` and can collect again; keep the newly-created closure - // in an outer handle and reload it after every such call. Without the outer - // handle, `set_bound_native_closure_name` protected the closure only inside - // its own scope and this function could return the now-forwarded from-space - // address. A caller such as Next's Reflect.get adapter observes that stale - // method value at an immediately-following `typeof` check (#8036). - let scope = crate::gc::RuntimeHandleScope::new(); - let instance_handle = scope.root_nanbox_f64(instance); - let private_brand_handle = private_brand.map(|brand| scope.root_nanbox_f64(brand)); - let closure_handle = scope.root_raw_mut_ptr(crate::closure::js_closure_alloc( - crate::closure::BOUND_METHOD_FUNC_PTR, - if private_brand_handle.is_some() { 4 } else { 3 }, - )); - // Capture-slot writes are scoped arguments to non-allocating stores, so - // the address cannot go stale inside the call. Each value is read from its - // own handle first, exactly as before. - let instance_value = instance_handle.get_nanbox_f64(); - closure_handle.with_mut_ptr::(|closure| { - crate::closure::js_closure_set_capture_f64(closure, 0, instance_value); - crate::closure::js_closure_set_capture_ptr(closure, 1, method_name_ptr as i64); - crate::closure::js_closure_set_capture_ptr(closure, 2, method_name_len as i64); - if let Some(brand) = &private_brand_handle { - crate::closure::js_closure_set_capture_f64(closure, 3, brand.get_nanbox_f64()); - } - }); - #[cfg(test)] - TEST_COLLECT_BOUND_METHOD_AFTER_CAPTURE_INIT.with(|armed| { - if armed.replace(false) { - let before = closure_handle - .with_mut_ptr::(|closure| closure as usize); - // The reload IS the subject of this hook: `across_mut` hands back - // the post-collection address without ever binding a pre-call one. - let (_, after) = closure_handle - .across_mut::(crate::gc::gc_collect_minor); - let after = after as usize; - TEST_BOUND_METHOD_MOVE.with(|trace| trace.set((before, after))); - } - }); - if !method_name_ptr.is_null() && method_name_len > 0 { - if let Ok(name) = unsafe { - std::str::from_utf8(std::slice::from_raw_parts(method_name_ptr, method_name_len)) - } { - closure_handle.with_mut_ptr::(|closure| { - set_bound_native_closure_name(closure, name) - }); - if let Some(length) = bound_native_method_length(name) { - closure_handle.with_mut_ptr::(|closure| { - set_builtin_closure_length(closure as usize, length) - }); - } else if let Some(class_id) = - class_id_from_method_receiver(instance_handle.get_nanbox_f64()) - { - // User class method bound as a value (`C.prototype.m`, `c.m`): - // stamp its spec `.length` from the registered param count so - // `C.prototype.m.length` reflects the declared arity instead of - // the closure's capture count (Test262 method `.length` tests). - if let Some(length) = - super::class_registry::class_method_bind_length(class_id, name) - { - closure_handle.with_mut_ptr::(|closure| { - set_builtin_closure_length(closure as usize, length) - }); - } - } - } - } - closure_handle.with_mut_ptr::(|closure| { - crate::value::js_nanbox_pointer(closure as i64) - }) -} +pub(crate) use class_method_bind::{ + test_collect_bound_method_after_capture_init, test_take_bound_method_move, +}; include!("native_module/class_method_values.rs"); diff --git a/crates/perry-runtime/src/object/native_module/class_method_bind.rs b/crates/perry-runtime/src/object/native_module/class_method_bind.rs new file mode 100644 index 0000000000..6b7c3a58af --- /dev/null +++ b/crates/perry-runtime/src/object/native_module/class_method_bind.rs @@ -0,0 +1,328 @@ +//! Class-method binding: the bound-method closures `obj.method` reads build +//! for class instances (`js_class_method_bind`, its snapshot and by-id +//! forms) and the private-brand-aware builder they share. + +use super::*; + +/// Build a "bound method" closure for `obj.method` PropertyGet on a known class +/// instance. The captures (instance, method_name_ptr, method_name_len) drive +/// `dispatch_bound_method` (closure.rs), which calls `js_native_call_method` +/// — that resolves the method through `CLASS_VTABLE_REGISTRY` for any class +/// registered by `js_register_class_method` at module init. +/// +/// Issue #446: previously a class method reference (`let f = obj.method`, +/// `typeof obj.method`, `arr.map(obj.method)`) silently lowered to the +/// generic property-bag lookup, which doesn't store prototype methods — +/// every such read returned `undefined`, so `typeof obj.method === "undefined"` +/// and a captured method ran no body when invoked. +/// +/// Method-name pointer is expected to be stable for the closure's lifetime; +/// codegen emits it from the per-module `.str.N.bytes` rodata global. +#[no_mangle] +pub extern "C" fn js_class_method_bind( + instance: f64, + method_name_ptr: *const u8, + method_name_len: usize, +) -> f64 { + if !method_name_ptr.is_null() && method_name_len > 0 { + if let Ok(name) = unsafe { + std::str::from_utf8(std::slice::from_raw_parts(method_name_ptr, method_name_len)) + } { + if matches!( + name, + "append" + | "delete" + | "entries" + | "forEach" + | "get" + | "getSetCookie" + | "has" + | "keys" + | "set" + | "Symbol.iterator" + | "@@iterator" + | "values" + ) { + let bits = instance.to_bits(); + if (bits >> 48) == 0x7FFD { + let id = (bits & 0x0000_FFFF_FFFF_FFFF) as i64; + if crate::value::addr_class::is_small_handle(id as usize) { + if let Some(dispatch) = handle_property_dispatch() { + let value = HANDLE_PROPERTY_BIND_REENTRY.with(|guard| { + if guard.get() { + None + } else { + guard.set(true); + let value = + unsafe { dispatch(id, method_name_ptr, method_name_len) }; + guard.set(false); + Some(value) + } + }); + if let Some(value) = value { + if value.to_bits() != crate::value::TAG_UNDEFINED { + return value; + } + } + } + } + } + } + } + } + + // Method IDENTITY (test262 class/elements): a class method is a single + // shared function object, so `c.m`, `c2.m` and `C.prototype.m` must all be + // the IDENTICAL value. Route every user-class method-as-value read through + // the per-`(owner_class, name)` cached canonical built by + // `class_prototype_method_value_for_name` instead of minting a fresh + // per-receiver closure here. The canonical captures the OWNER class's + // prototype-ref (capture 0); `dispatch_bound_method` recognises that marker + // and supplies the call-site `this` (IMPLICIT_THIS) so invocations still see + // the right receiver — e.g. the `this.m = this.m.bind(this)` idiom rebinds + // correctly, and a bare `const f = c.m; f()` runs with the spec `this`. + // + // Guard against re-entry from `class_prototype_method_value_for_name` + // itself: it builds the canonical by calling `build_bound_method_closure` + // directly (NOT this function), so the cache is populated without looping. + if !method_name_ptr.is_null() && method_name_len > 0 { + if let Ok(name) = unsafe { + std::str::from_utf8(std::slice::from_raw_parts(method_name_ptr, method_name_len)) + } { + // #7689: a CONSTRUCTOR class-ref receiver (`const f = C.m`) must + // never canonicalize to the INSTANCE vtable method of the same + // name — in JS `C.m` sees only statics (`class C { static lex(){} + // lex(){} }` has `C.lex` === the static; the instance `lex` lives + // on `C.prototype`). `class_id_from_method_receiver` treats a + // class ref like an instance, so marked's `const lexer2 = + // _Lexer.lex; lexer2(src, opt)` extracted the instance `lex`, + // whose bare invocation read `this.options` off an unconstructed + // receiver. Fall through to `build_bound_method_closure`: its + // call-time dispatch (`js_native_call_method`'s 0x7FFE arm) + // resolves statics-first for constructor refs. PROTOTYPE refs + // (`C.prototype.m`) keep the canonical path — the instance method + // is exactly what they name. + let receiver_class_ref = class_ref_id(instance); + let receiver_is_constructor_ref = + receiver_class_ref.is_some() && class_prototype_ref_id(instance).is_none(); + if !receiver_is_constructor_ref && bound_native_method_length(name).is_none() { + if let Some(class_id) = + class_id_from_method_receiver_known(instance, receiver_class_ref) + { + let private_owner = super::take_private_method_owner_hint(name); + if let Some(owner) = private_owner + .or_else(|| super::class_registry::method_owner_class_id(class_id, name)) + { + // [[Get]] order: an OWN data property of this name + // shadows the prototype method. The ubiquitous + // `this.m = this.m.bind(this)` idiom installs an own `m` + // (a bound function), so `obj.m` must read that own value + // back — not the shared prototype method. Skipping this + // both returned the wrong identity (`obj.m === + // C.prototype.m` where Node says false) and looped when + // the canonical re-resolved `m` by name. A class + // prototype-ref receiver has no own-property bag, so this + // check is naturally a no-op there. + let recv_jsv = JSValue::from_bits(instance.to_bits()); + if private_owner.is_none() + && recv_jsv.is_pointer() + && !super::class_registry::is_registered_class_prototype_object( + crate::value::js_nanbox_get_pointer(instance) as usize, + ) + { + let obj = recv_jsv.as_pointer::(); + if crate::value::addr_class::is_above_handle_band(obj as usize) { + let key = crate::string::js_string_from_bytes( + method_name_ptr, + method_name_len as u32, + ); + if let Some(own) = + unsafe { super::own_data_field_by_name(obj, key) } + { + if own.bits() != crate::value::TAG_UNDEFINED { + return f64::from_bits(own.bits()); + } + } + } + } + let lexical_owner = private_owner + .and_then(|owner| super::current_private_lexical_brand_value(owner)); + let canonical = lexical_owner + .or_else(|| private_evaluation_brand_value(instance)) + .map(|brand| class_evaluation_method_value_for_name(owner, name, brand)) + .unwrap_or_else(|| class_prototype_method_value_for_name(owner, name)); + if canonical.to_bits() != crate::value::TAG_UNDEFINED { + return canonical; + } + } + } + } + } + } + + build_bound_method_closure(instance, method_name_ptr, method_name_len) +} + +/// Perry's intentional `this.method` value-read contract: capture the instance +/// at read time so a later own-property replacement cannot change the method's +/// receiver or target. Ordinary `obj.method` reads still use +/// [`js_class_method_bind`] and its canonical per-class value identity. +/// +/// An own value that already exists wins at read time. This keeps constructor +/// arrow overrides (`this.m = () => ...; const f = this.m`) on the ordinary +/// property path instead of replacing them with a prototype-method snapshot. +#[no_mangle] +pub extern "C" fn js_class_method_snapshot_bind( + instance: f64, + method_name_ptr: *const u8, + method_name_len: usize, +) -> f64 { + let value = JSValue::from_bits(instance.to_bits()); + if !value.is_pointer() + || class_registry::is_class_object_value(instance) + || class_id_from_method_receiver(instance).is_none() + { + return js_class_method_bind(instance, method_name_ptr, method_name_len); + } + + let scope = crate::gc::RuntimeHandleScope::new(); + let instance_handle = scope.root_nanbox_f64(instance); + if !method_name_ptr.is_null() && method_name_len > 0 { + let key = crate::string::js_string_from_bytes(method_name_ptr, method_name_len as u32); + let key_handle = scope.root_string_ptr(key); + let current = instance_handle.get_nanbox_f64(); + let obj = JSValue::from_bits(current.to_bits()).as_pointer::(); + if crate::value::addr_class::is_above_handle_band(obj as usize) { + let own = key_handle.with_const_ptr::(|key| unsafe { + super::own_data_field_by_name(obj, key) + }); + if let Some(own) = own { + if own.bits() != crate::value::TAG_UNDEFINED { + return f64::from_bits(own.bits()); + } + } + } + } + + build_bound_method_closure( + instance_handle.get_nanbox_f64(), + method_name_ptr, + method_name_len, + ) +} + +/// By-ID sibling of `js_class_method_bind` for static-name lowering. +/// +/// Current codegen passes an immutable AOT descriptor. Legacy heap/short-string +/// ids remain accepted for ABI compatibility. +#[no_mangle] +pub extern "C" fn js_class_method_bind_by_id(instance: f64, method_id: i64) -> f64 { + let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let Some(name_ref) = crate::string::perry_string_ref_from_dispatch_id(method_id, &mut scratch) + else { + return f64::from_bits(crate::value::TAG_UNDEFINED); + }; + js_class_method_bind(instance, name_ref.ptr, name_ref.len) +} + +#[cfg(feature = "keepalive-anchors")] +#[used(compiler)] +static KEEP_CLASS_METHOD_BIND_BY_ID: extern "C" fn(f64, i64) -> f64 = js_class_method_bind_by_id; + +#[cfg(test)] +thread_local! { + static TEST_COLLECT_BOUND_METHOD_AFTER_CAPTURE_INIT: std::cell::Cell = + const { std::cell::Cell::new(false) }; + static TEST_BOUND_METHOD_MOVE: std::cell::Cell<(usize, usize)> = + const { std::cell::Cell::new((0, 0)) }; +} + +#[cfg(test)] +pub(crate) fn test_collect_bound_method_after_capture_init() { + TEST_COLLECT_BOUND_METHOD_AFTER_CAPTURE_INIT.with(|armed| armed.set(true)); + TEST_BOUND_METHOD_MOVE.with(|trace| trace.set((0, 0))); +} + +#[cfg(test)] +pub(crate) fn test_take_bound_method_move() -> (usize, usize) { + TEST_BOUND_METHOD_MOVE.with(|trace| trace.replace((0, 0))) +} + +pub(super) fn build_bound_method_closure_with_private_brand( + instance: f64, + method_name_ptr: *const u8, + method_name_len: usize, + private_brand: Option, +) -> f64 { + // `js_closure_alloc` can collect before it returns, so keep the receiver + // live across that allocation. The metadata installation below allocates a + // string for `.name` and can collect again; keep the newly-created closure + // in an outer handle and reload it after every such call. Without the outer + // handle, `set_bound_native_closure_name` protected the closure only inside + // its own scope and this function could return the now-forwarded from-space + // address. A caller such as Next's Reflect.get adapter observes that stale + // method value at an immediately-following `typeof` check (#8036). + let scope = crate::gc::RuntimeHandleScope::new(); + let instance_handle = scope.root_nanbox_f64(instance); + let private_brand_handle = private_brand.map(|brand| scope.root_nanbox_f64(brand)); + let closure_handle = scope.root_raw_mut_ptr(crate::closure::js_closure_alloc( + crate::closure::BOUND_METHOD_FUNC_PTR, + if private_brand_handle.is_some() { 4 } else { 3 }, + )); + // Capture-slot writes are scoped arguments to non-allocating stores, so + // the address cannot go stale inside the call. Each value is read from its + // own handle first, exactly as before. + let instance_value = instance_handle.get_nanbox_f64(); + closure_handle.with_mut_ptr::(|closure| { + crate::closure::js_closure_set_capture_f64(closure, 0, instance_value); + crate::closure::js_closure_set_capture_ptr(closure, 1, method_name_ptr as i64); + crate::closure::js_closure_set_capture_ptr(closure, 2, method_name_len as i64); + if let Some(brand) = &private_brand_handle { + crate::closure::js_closure_set_capture_f64(closure, 3, brand.get_nanbox_f64()); + } + }); + #[cfg(test)] + TEST_COLLECT_BOUND_METHOD_AFTER_CAPTURE_INIT.with(|armed| { + if armed.replace(false) { + let before = closure_handle + .with_mut_ptr::(|closure| closure as usize); + // The reload IS the subject of this hook: `across_mut` hands back + // the post-collection address without ever binding a pre-call one. + let (_, after) = closure_handle + .across_mut::(crate::gc::gc_collect_minor); + let after = after as usize; + TEST_BOUND_METHOD_MOVE.with(|trace| trace.set((before, after))); + } + }); + if !method_name_ptr.is_null() && method_name_len > 0 { + if let Ok(name) = unsafe { + std::str::from_utf8(std::slice::from_raw_parts(method_name_ptr, method_name_len)) + } { + closure_handle.with_mut_ptr::(|closure| { + set_bound_native_closure_name(closure, name) + }); + if let Some(length) = bound_native_method_length(name) { + closure_handle.with_mut_ptr::(|closure| { + set_builtin_closure_length(closure as usize, length) + }); + } else if let Some(class_id) = + class_id_from_method_receiver(instance_handle.get_nanbox_f64()) + { + // User class method bound as a value (`C.prototype.m`, `c.m`): + // stamp its spec `.length` from the registered param count so + // `C.prototype.m.length` reflects the declared arity instead of + // the closure's capture count (Test262 method `.length` tests). + if let Some(length) = + super::class_registry::class_method_bind_length(class_id, name) + { + closure_handle.with_mut_ptr::(|closure| { + set_builtin_closure_length(closure as usize, length) + }); + } + } + } + } + closure_handle.with_mut_ptr::(|closure| { + crate::value::js_nanbox_pointer(closure as i64) + }) +} diff --git a/crates/perry-runtime/src/typed_feedback/tests.rs b/crates/perry-runtime/src/typed_feedback/tests.rs index 1d8e6d93c3..f17b467309 100644 --- a/crates/perry-runtime/src/typed_feedback/tests.rs +++ b/crates/perry-runtime/src/typed_feedback/tests.rs @@ -1235,6 +1235,10 @@ fn representation_lowering_helpers_have_lto_keepalive_anchors() { env!("CARGO_MANIFEST_DIR"), "/src/object/native_module.rs" )); + let class_method_bind = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/object/native_module/class_method_bind.rs" + )); let guards = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/src/typed_feedback/guards.rs" @@ -1353,7 +1357,7 @@ fn representation_lowering_helpers_have_lto_keepalive_anchors() { "js_native_call_method_apply_by_id", ), ( - native_module, + class_method_bind, "KEEP_CLASS_METHOD_BIND_BY_ID", "static KEEP_CLASS_METHOD_BIND_BY_ID: extern \"C\" fn(f64, i64) -> f64", "js_class_method_bind_by_id", From 98c407dfd36f15dc83812d2cb341f3d2878548a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 22:53:09 +0000 Subject: [PATCH 27/29] runtime: repoint the class-method-bind holder entries; read the statics-bag test string through OwnedStringBytes The move to native_module/class_method_bind.rs left the gc_runtime_root_holders inventory and frontier entries for TEST_BOUND_METHOD_MOVE and TEST_COLLECT_BOUND_METHOD_AFTER_CAPTURE_INIT naming native_module.rs; they now name the file the cells live in. The LTO keepalive-anchor test no longer reads native_module.rs, so its unused include is dropped (-D warnings). The statics-bag regression test open-coded the StringHeader payload offset (the string payload-access ratchet counted one new inline offset); it now copies the payload with OwnedStringBytes::copy_from_header. --- crates/perry-runtime/src/object/class_value.rs | 9 ++------- crates/perry-runtime/src/typed_feedback/tests.rs | 4 ---- scripts/gc_runtime_root_holders.json | 4 ++-- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/crates/perry-runtime/src/object/class_value.rs b/crates/perry-runtime/src/object/class_value.rs index c8872d2eef..c7e2984549 100644 --- a/crates/perry-runtime/src/object/class_value.rs +++ b/crates/perry-runtime/src/object/class_value.rs @@ -684,13 +684,8 @@ mod tests { let got = crate::value::JSValue::from_bits(got.to_bits()); let hdr = got.as_string_ptr(); assert!(!hdr.is_null()); - let bytes = unsafe { - std::slice::from_raw_parts( - (hdr as *const u8).add(std::mem::size_of::()), - (*hdr).byte_len as usize, - ) - }; - assert_eq!(bytes, text.as_bytes()); + let bytes = unsafe { crate::string::OwnedStringBytes::copy_from_header(hdr) }; + assert_eq!(bytes.as_bytes(), text.as_bytes()); let keys: Vec = class_static_entries(cid) .into_iter() .map(|(k, _)| k) diff --git a/crates/perry-runtime/src/typed_feedback/tests.rs b/crates/perry-runtime/src/typed_feedback/tests.rs index f17b467309..27611fa8f5 100644 --- a/crates/perry-runtime/src/typed_feedback/tests.rs +++ b/crates/perry-runtime/src/typed_feedback/tests.rs @@ -1231,10 +1231,6 @@ fn typed_feedback_array_loop_helpers_have_lto_keepalive_anchors() { #[test] fn representation_lowering_helpers_have_lto_keepalive_anchors() { let native_abi = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/native_abi.rs")); - let native_module = include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/src/object/native_module.rs" - )); let class_method_bind = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/src/object/native_module/class_method_bind.rs" diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 6d2851c10d..507c9eb9d6 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -936,7 +936,7 @@ "why": "#[cfg(test)] Cell selects the unchanged slow Get for differential tests. Holds only a boolean, never a GC address or JSValue, and is absent from shipped binaries." }, { - "file": "crates/perry-runtime/src/object/native_module.rs", + "file": "crates/perry-runtime/src/object/native_module/class_method_bind.rs", "name": "TEST_BOUND_METHOD_MOVE", "verdict": "test_only", "why": "#[cfg(test)] diagnostic trace for the bound-method moving-GC regression: records the (before, after) addresses a test-forced minor produced so the test can assert the relocation happened. The addresses are compared as integers, never dereferenced, and the cell is dead in a shipped binary." @@ -3883,7 +3883,7 @@ "name": "HANDLE_PROPERTY_BIND_REENTRY" }, { - "file": "crates/perry-runtime/src/object/native_module.rs", + "file": "crates/perry-runtime/src/object/native_module/class_method_bind.rs", "name": "TEST_COLLECT_BOUND_METHOD_AFTER_CAPTURE_INIT" }, { From e6296cf4c74e6d5d7af514c955bbe88b23576c81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 29 Sep 2026 05:27:26 +0000 Subject: [PATCH 28/29] fix(codegen): a class capture refresh roots its capture array in one slot, and only when a capture can collect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit df94a3a44 rooted each js_array_push_f64 result in a fresh temp-root slot. tsc's module-scope closure holds 711 capture refreshes of ~75 captures each, so that added 53,396 rooted stores (seven blocks apiece): the function went from 198k to 572k blocks and LLVM's mem2reg (IDF calculation per alloca) went quadratic — tsc compiled in 50-80+ min instead of ~15-20. The array is now the canonical rooted accumulator (one slot, republished by each push), and it is rooted only when a capture can collect; plain local and boxed-variable captures have no collection point between two pushes. tsc's closure is back to the base instruction count exactly. --- .../src/expr/slice7_rooting_tests.rs | 49 ++++++++++++++++++ .../src/expr/static_field_meta.rs | 51 ++++++++++--------- 2 files changed, 75 insertions(+), 25 deletions(-) diff --git a/crates/perry-codegen/src/expr/slice7_rooting_tests.rs b/crates/perry-codegen/src/expr/slice7_rooting_tests.rs index e566d0988e..1e566a013c 100644 --- a/crates/perry-codegen/src/expr/slice7_rooting_tests.rs +++ b/crates/perry-codegen/src/expr/slice7_rooting_tests.rs @@ -608,3 +608,52 @@ fn class_expr_capture_refresh_rereads_its_capture_array_below_each_capture() { (line {alloc}):\n{ir}" ); } + +fn capture_refresh(name: &str, captures: Vec) -> String { + compile_body( + name, + vec![Stmt::Expr(Expr::RefreshClassExprCaptures { + class_value: Box::new(Expr::Undefined), + captures, + env_class: None, + })], + ) +} + +/// The capture array is ONE accumulator: however many captures collect, the +/// refresh holds one rooted slot, republished by each push. A slot per push +/// grew tsc's module-scope closure (hundreds of refreshes of ~75 captures +/// each) by ~370k blocks and made its compile 3-4x slower. +#[test] +fn class_expr_capture_refresh_roots_one_slot_for_any_capture_count() { + let one = capture_refresh("refresh_one", vec![allocating("a")]); + let three = capture_refresh( + "refresh_three", + vec![allocating("a"), allocating("b"), allocating("c")], + ); + assert_eq!(call_count(&one, "js_array_push_f64"), 1, "{one}"); + assert_eq!(call_count(&three, "js_array_push_f64"), 3, "{three}"); + assert_eq!( + temp_root_slot_width(&one), + temp_root_slot_width(&three), + "three collecting captures must reuse the one capture-array slot:\n{three}" + ); +} + +/// Captures that cannot collect leave no collection point between two pushes, +/// so the refresh emits no root at all: the same slot width as a refresh with +/// no captures. +#[test] +fn class_expr_capture_refresh_over_inert_captures_emits_no_root() { + let none = capture_refresh("refresh_none", vec![]); + let inert = capture_refresh( + "refresh_inert", + vec![Expr::Number(1.0), Expr::Number(2.0), Expr::Undefined], + ); + assert_eq!(call_count(&inert, "js_array_push_f64"), 3, "{inert}"); + assert_eq!( + temp_root_slot_width(&inert), + temp_root_slot_width(&none), + "inert captures cannot collect, so the capture array needs no slot:\n{inert}" + ); +} diff --git a/crates/perry-codegen/src/expr/static_field_meta.rs b/crates/perry-codegen/src/expr/static_field_meta.rs index d38c182eea..1a2c5bacc4 100644 --- a/crates/perry-codegen/src/expr/static_field_meta.rs +++ b/crates/perry-codegen/src/expr/static_field_meta.rs @@ -309,19 +309,23 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let cap_len = captures.len().to_string(); // The capture array is live across every capture's lowering, and a // capture can collect (a property read through an IC miss, a - // getter): it lives in a root slot, and each push re-reads it from - // there. Each push's result (the array may grow) is rooted in turn; - // only the last one becomes a register, after the last capture. - use crate::rooting::{call_rooted, call_with_roots, Arg}; - let caps_arr = if captures.is_empty() { - ctx.block().call_void("js_tdz_suppress_begin", &[]); - ctx.block().call(I64, "js_array_alloc", &[(I32, &cap_len)]) - } else { - let first = call_rooted(ctx, I64, "js_array_alloc", &[Arg::Plain(I32, &cap_len)]); - ctx.block().call_void("js_tdz_suppress_begin", &[]); - let last = captures.len() - 1; - let pushed = (|| -> Result { - let mut current = first.clone(); + // getter): then it is an accumulator in ONE root slot, re-read by + // each push and republished with the push's result (the array may + // grow). A refresh whose captures cannot collect — the common case, + // plain local and boxed-variable reads — has no collection point + // between two pushes, so it emits no slot at all: a per-push slot + // costs seven blocks, and a module-scope closure holding several + // hundred class refreshes of ~75 captures each grew by ~370k blocks, + // which made LLVM's mem2reg quadratic (tsc compiled 3-4x slower). + let protect = crate::rooting::any_operand_may_collect(ctx, captures.iter()); + let arr = ctx.block().call(I64, "js_array_alloc", &[(I32, &cap_len)]); + let caps_arr = crate::rooting::with_rooted_accumulator( + ctx, + crate::rooting::Repr::Ptr, + &arr, + protect, + |ctx, acc| { + ctx.block().call_void("js_tdz_suppress_begin", &[]); for (index, capture) in captures.iter().enumerate() { let value = lower_expr(ctx, capture)?; if let Some(env_class) = env_class { @@ -333,19 +337,16 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { capture, ); } - let args = [Arg::Root(¤t), Arg::Plain(DOUBLE, &value)]; - if index == last { - return Ok(call_with_roots(ctx, I64, "js_array_push_f64", &args)); - } - current = call_rooted(ctx, I64, "js_array_push_f64", &args); + acc.advance( + ctx, + "js_array_push_f64", + &[crate::rooting::Arg::Plain(DOUBLE, &value)], + ); } - unreachable!("the last capture returns") - })(); - // A stack cut: releases every slot pushed after `first` too, on - // the error path as well. - first.release(ctx); - pushed? - }; + Ok(()) + }, + |_, current| Ok(current.to_string()), + )?; 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 From 54d9d03608c742f661426b5b1ea91c35d5ef4114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 29 Sep 2026 06:51:11 +0000 Subject: [PATCH 29/29] gc_effects: regenerate linux-x86_64 table after merging perf-class-function-objects --- crates/perry-codegen/src/gc_effects/linux-x86_64.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv b/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv index b3be7889b1..ab97e7e569 100644 --- a/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv +++ b/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv @@ -2145,7 +2145,7 @@ js_object_get_symbol_property_ic_miss Reenters js_object_get_symbol_then_field_ic_miss Reenters js_object_group_by Reenters js_object_has_own Reenters -js_object_has_own_symbol Reenters +js_object_has_own_symbol AllocOnly js_object_has_property Reenters js_object_is Reenters js_object_is_extensible Reenters