From 39766e0b81cc44e5517b6d15e7cae7bedf9093af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 3 Oct 2026 13:25:14 +0000 Subject: [PATCH 1/5] runtime: a fresh class template memoizes its shapes in its own cell, not in a table #11780 kept each per-evaluation class template's final class-object and prototype ShapeIds, slot fills and internal-key transitions in two thread-local maps keyed by template class id (TEMPLATES, PROTOTYPES), both allowlisted in the registry lifetime check. They now live in the template's own record: an image static codegen emits once per template (@perry_ctpl.), passed to js_class_evaluation_object and js_class_object_set_ctor_caps at the evaluation site and registered on the template's vtable entry for the paths that start from a class object. ShapeIds name one agent's shape records, so the cell answers only the thread that recorded it; any other thread takes the ordinary path. Codegen writes only the cell's length; every record is checked against it. The two allowlist entries are removed: registry_lifetime_check is red on #11780's head without them and green here. --- .../src/codegen/fresh_class_templates.rs | 20 + crates/perry-codegen/src/codegen/mod.rs | 2 +- .../perry-codegen/src/codegen/string_pool.rs | 28 ++ .../src/expr/static_field_meta.rs | 14 +- .../src/gc_effects/linux-x86_64.tsv | 1 + .../src/gc_effects/macos-aarch64.tsv | 1 + .../src/gc_effects/windows-x86_64.tsv | 1 + .../src/runtime_decls/objects.rs | 4 +- .../runtime_decls/stdlib_ffi/language_core.rs | 1 + .../perry-codegen/src/wasm32/runtime_abi.tsv | 5 +- .../src/object/class_registry.rs | 9 +- .../src/object/class_registry/state.rs | 23 + .../field_get_set/class_object_props.rs | 6 +- .../field_get_set/class_object_template.rs | 442 +++++++++++++----- .../class_object_template_tests.rs | 52 ++- scripts/registry_lifetime_allowlist.json | 12 - 16 files changed, 481 insertions(+), 140 deletions(-) diff --git a/crates/perry-codegen/src/codegen/fresh_class_templates.rs b/crates/perry-codegen/src/codegen/fresh_class_templates.rs index 37da5085a5..a818c726a9 100644 --- a/crates/perry-codegen/src/codegen/fresh_class_templates.rs +++ b/crates/perry-codegen/src/codegen/fresh_class_templates.rs @@ -6,6 +6,26 @@ use perry_hir::{Expr, Stmt}; use std::collections::HashSet; +/// The template cell of per-evaluation template `cid`: the template's own +/// record, an internal `[N x i64]` the module's string-pool initializer +/// defines and registers (`js_register_class_template_cell`) and the +/// evaluation site passes to `js_class_evaluation_object` and +/// `js_class_object_set_ctor_caps`. Codegen writes only its length in words +/// (word 0); the runtime owns the rest of its layout +/// (`class_object_template::TemplateCell`). +pub(crate) fn template_cell_global(cid: u32) -> String { + format!("perry_ctpl.{cid}") +} + +/// How many words template cell of `class` gets: the runtime's fixed words +/// plus two per slot of the class object's and the prototype's final shapes +/// (`length`, `name`, one per static method and the pinned parent; +/// `constructor` and one per method). The runtime checks every record against +/// the length, so a short cell only declines to memoize. +pub(crate) fn template_cell_words(class: Option<&perry_hir::Class>) -> usize { + 32 + 2 * (4 + class.map_or(16, |c| c.static_methods.len() + c.methods.len())) +} + /// Every `ClassExprFresh` template named anywhere in `hir`: module init, /// function bodies, class members and nested closures. pub(crate) fn fresh_class_templates(hir: &perry_hir::Module) -> HashSet { diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 8b4721976d..68d86af161 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -204,7 +204,7 @@ mod emission_order_tests; mod entry; pub mod entry_outline; mod export_value_wrappers; -mod fresh_class_templates; +pub(crate) mod fresh_class_templates; pub(crate) mod func_registry; mod function; mod function_source_header; diff --git a/crates/perry-codegen/src/codegen/string_pool.rs b/crates/perry-codegen/src/codegen/string_pool.rs index e660d3ac49..683a836dec 100644 --- a/crates/perry-codegen/src/codegen/string_pool.rs +++ b/crates/perry-codegen/src/codegen/string_pool.rs @@ -1146,6 +1146,34 @@ pub(super) fn emit_string_pool( .iter() .filter_map(|name| class_ids.get(name).copied()) .collect(); + // Each per-evaluation template's own record, its template cell + // (`fresh_class_templates::template_cell_global`), registered on the + // template's vtable entry for the runtime paths that start from one of its + // class objects. + let mut fresh_cells: Vec<(u32, usize)> = fresh_class_templates + .iter() + .filter_map(|name| { + let cid = *class_ids.get(name)?; + let words = + super::fresh_class_templates::template_cell_words(classes.get(name).copied()); + Some((cid, words)) + }) + .collect(); + fresh_cells.sort_unstable(); + fresh_cells.dedup_by_key(|cell| cell.0); + for (cid, words) in fresh_cells { + let global = super::fresh_class_templates::template_cell_global(cid); + chunker.module().add_raw_global(format!( + "@{global} = internal global [{words} x i64] [i64 {words}{}]", + ", i64 0".repeat(words - 1) + )); + let blk = chunker.current_block(); + let cell_i64 = blk.ptrtoint(&format!("@{global}"), I64); + blk.call_void( + "js_register_class_template_cell", + &[(I64, &cid.to_string()), (I64, &cell_i64)], + ); + } method_triples.sort_unstable(); for ( cid, diff --git a/crates/perry-codegen/src/expr/static_field_meta.rs b/crates/perry-codegen/src/expr/static_field_meta.rs index 80c58b8bfd..9610b00058 100644 --- a/crates/perry-codegen/src/expr/static_field_meta.rs +++ b/crates/perry-codegen/src/expr/static_field_meta.rs @@ -608,6 +608,17 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } => { let template_cid = ctx.class_ids.get(template).copied().unwrap_or(0); let tcid_str = template_cid.to_string(); + // The template's own record (its template cell), which the + // module's string-pool initializer defines for every template it + // evaluates. + let cell = if template_cid == 0 { + "null".to_string() + } else { + format!( + "@{}", + crate::codegen::fresh_class_templates::template_cell_global(template_cid) + ) + }; // Room for the evaluation's own `length`, `name` and static // methods, its pinned parent, its captured environment and its // prototype object besides its static fields, so the template's @@ -645,6 +656,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { (I32, &tcid_str), (I32, &nfields), (I32, &field_mask.to_string()), + (PTR, &cell), ], ); // #7154: the fresh class object is a raw SSA register while the @@ -782,7 +794,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // the template's final shape (`class_object_template`). ctx.block().call_void( "js_class_object_set_ctor_caps", - &[(I64, &obj), (DOUBLE, &caps_box)], + &[(I64, &obj), (DOUBLE, &caps_box), (PTR, &cell)], ); // A guarded class environment learns this evaluation; the // first one publishes its captures into the slots. 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 3dea7e03e2..a92af889b4 100644 --- a/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv +++ b/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv @@ -2755,6 +2755,7 @@ js_register_class_static_method_bind_length Leaf js_register_class_static_method_entry Reenters js_register_class_static_setter Reenters js_register_class_string_member_order Leaf +js_register_class_template_cell Leaf js_register_class_to_string_tag Leaf js_register_embedded_asset Leaf js_register_embedded_text_asset Leaf diff --git a/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv b/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv index dcf54e3cdd..03c4feaaac 100644 --- a/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv +++ b/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv @@ -2753,6 +2753,7 @@ js_register_class_static_method_bind_length Reenters js_register_class_static_method_entry Reenters js_register_class_static_setter Reenters js_register_class_string_member_order Leaf +js_register_class_template_cell Leaf js_register_class_to_string_tag Reenters js_register_embedded_asset Leaf js_register_embedded_text_asset 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 cfe7966fb5..f3c41302f1 100644 --- a/crates/perry-codegen/src/gc_effects/windows-x86_64.tsv +++ b/crates/perry-codegen/src/gc_effects/windows-x86_64.tsv @@ -2753,6 +2753,7 @@ js_register_class_static_method_bind_length Leaf js_register_class_static_method_entry Reenters js_register_class_static_setter Reenters js_register_class_string_member_order Leaf +js_register_class_template_cell Leaf js_register_class_to_string_tag Leaf js_register_embedded_asset Leaf js_register_embedded_text_asset Leaf diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index 89b8fe1070..71b637de79 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -117,8 +117,8 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { // new/instanceof read class_id from it. module.declare_function("js_object_mark_class", VOID, &[I64]); // #6438: pin a per-evaluation class object's own parent edge. - module.declare_function("js_class_evaluation_object", I64, &[I32, I32, I32]); - module.declare_function("js_class_object_set_ctor_caps", VOID, &[I64, DOUBLE]); + module.declare_function("js_class_evaluation_object", I64, &[I32, I32, I32, PTR]); + module.declare_function("js_class_object_set_ctor_caps", VOID, &[I64, DOUBLE, PTR]); // Shape-cache-aware variant: pre-populates keys_array via SHAPE_INLINE_CACHE, // so subsequent field stores can use index-based set_field (skipping the // per-call linear key-search done by js_object_set_field_by_name). 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 314de9c357..8958319085 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 @@ -435,6 +435,7 @@ pub(crate) fn declare_core(module: &mut LlModule) { VOID, &[I64, I64, I64, I64], ); + module.declare_function("js_register_class_template_cell", VOID, &[I64, I64]); module.declare_function("js_static_method_entry_leave", VOID, &[]); module.declare_function( "js_class_static_call_guard", diff --git a/crates/perry-codegen/src/wasm32/runtime_abi.tsv b/crates/perry-codegen/src/wasm32/runtime_abi.tsv index 9ae07690e7..0950fe1816 100644 --- a/crates/perry-codegen/src/wasm32/runtime_abi.tsv +++ b/crates/perry-codegen/src/wasm32/runtime_abi.tsv @@ -589,7 +589,7 @@ js_class_env_register_slot void i32u,i32u,ptr js_class_env_register_state void i32u,ptr js_class_env_set void f64,i32u,i32u,f64 js_class_env_stamp f64 f64,i32u,f64 -js_class_evaluation_object i64 i32u,i32u,i32u +js_class_evaluation_object i64 i32u,i32u,i32u,ptr js_class_field_add f64 f64,f64,f64 js_class_field_get_ic f64 i64,f64,i32u,i32u,ptr,i32u,i32s,ptr js_class_field_get_ic_fast f64 i64,f64,i32u,i32u,ptr,i32u,i32s,ptr @@ -606,7 +606,7 @@ js_class_method_entry_enter_home i64 i32u,i64,i64 js_class_method_entry_leave void i64 js_class_method_snapshot_bind f64 f64,ptr,usize js_class_object_refresh_capture_values void f64,i64,f64 -js_class_object_set_ctor_caps void i64,f64 +js_class_object_set_ctor_caps void i64,f64,ptr 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 @@ -3232,6 +3232,7 @@ js_register_class_static_method_bind_length void i64,ptr,i64,i64 js_register_class_static_method_entry void i64,ptr,i64,i64 js_register_class_static_setter void i64,ptr,i64,i64,i32s js_register_class_string_member_order void i64,ptr,i64,i64,i64 +js_register_class_template_cell void i64,i64 js_register_class_to_string_tag void i32u,i64 js_register_embedded_asset void ptr,usize,ptr,usize js_register_embedded_text_asset void ptr,usize,ptr,usize diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index 224b14b233..d25310e7a8 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -94,10 +94,11 @@ 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_clear_defined_attrs, class_static_defined_attrs, - class_static_key_deleted, class_static_prototype, class_static_prototype_is_nulled, - class_static_prototype_root_clear, class_static_prototype_root_store, - class_static_set_defined_attrs, decl_prototype_identity_id, global_object_prototype_bits, + class_set_template_cell, class_static_alias_sync, class_static_clear_defined_attrs, + class_static_defined_attrs, class_static_key_deleted, class_static_prototype, + class_static_prototype_is_nulled, class_static_prototype_root_clear, + class_static_prototype_root_store, class_static_set_defined_attrs, class_template_cell, + decl_prototype_identity_id, global_object_prototype_bits, is_bound_native_constructor_closure_value, is_non_constructable_builtin_function_value, parent_closure_in_chain, template_has_class_objects, throw_non_constructable_builtin_function, CLASS_OBJECT_EVER, diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index 2b333eecdd..b38797d612 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -383,6 +383,10 @@ pub struct ClassVTable { pub methods: HashMap, pub accessors: HashMap, pub private_accessors: HashMap, + /// The address of the class's template cell (`@perry_ctpl.`) when + /// the class is a per-evaluation template (`class_object_template`); 0 + /// otherwise. + pub template_cell: usize, } impl ClassVTable { @@ -473,6 +477,25 @@ pub static CLASS_STRING_MEMBER_ORDERS: ImageTable>>> = ImageTable::new(|image| &image.method_bind_lengths); +/// Record `cell` as per-evaluation template `class_id`'s template cell. +pub(crate) fn class_set_template_cell(class_id: u32, cell: usize) { + let Ok(mut guard) = CLASS_VTABLE_REGISTRY.write() else { + return; + }; + guard + .get_or_insert_with(crate::fast_hash::new_ptr_hash_map) + .entry(class_id) + .or_default() + .template_cell = cell; +} + +/// Per-evaluation template `class_id`'s template cell, if one was registered. +pub(crate) fn class_template_cell(class_id: u32) -> Option { + let guard = CLASS_VTABLE_REGISTRY.read().ok()?; + let cell = guard.as_ref()?.get(&class_id)?.template_cell; + (cell != 0).then_some(cell) +} + /// The closure-convention entry registered for method `name` of per-evaluation /// class `class_id` (its vtable entry's `entry`), if any. pub(crate) fn class_method_entry(class_id: u32, name: &str) -> Option { 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 7e507145d9..1742dff6f5 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 @@ -140,8 +140,9 @@ unsafe fn class_evaluation_prototype_value(obj: *const ObjectHeader) -> f64 { let proto_value = proto .with_mut_ptr::(|p| crate::value::js_nanbox_pointer(p as i64)); class.with_mut_ptr::(|class| { - super::class_object_template::class_object_add_internal( + super::class_object_template::class_object_add_internal_for( class, + class_id, super::class_object_template::InternalKey::EvaluationPrototype, proto_value, ) @@ -239,8 +240,9 @@ unsafe fn class_evaluation_prototype_value(obj: *const ObjectHeader) -> f64 { let proto_value = proto .with_mut_ptr::(|proto| crate::value::js_nanbox_pointer(proto as i64)); class.with_mut_ptr::(|class| { - super::class_object_template::class_object_add_internal( + super::class_object_template::class_object_add_internal_for( class, + class_id, super::class_object_template::InternalKey::EvaluationPrototype, proto_value, ) diff --git a/crates/perry-runtime/src/object/field_get_set/class_object_template.rs b/crates/perry-runtime/src/object/field_get_set/class_object_template.rs index 68158cf056..8dacc0d911 100644 --- a/crates/perry-runtime/src/object/field_get_set/class_object_template.rs +++ b/crates/perry-runtime/src/object/field_get_set/class_object_template.rs @@ -23,11 +23,13 @@ //! the evaluation, linked to the evaluation's parent prototype. //! //! What the template remembers is ShapeIds and what fills each slot; it holds -//! nothing about any object. Its records are external carriers for the -//! agent's life, so no id it names can come to name other facts. +//! nothing about any object. It remembers them in its own image record, the +//! template cell ([`TemplateCell`]) codegen emits once per template: no table +//! is keyed by the template. The shape records it names are external carriers +//! for the agent's life, so no id it names can come to name other facts. use super::*; -use std::rc::Rc; +use std::sync::atomic::{AtomicU64, Ordering}; /// How one slot of a template's final class-object shape is filled. #[derive(Clone, Copy, PartialEq, Eq)] @@ -45,22 +47,206 @@ enum Fill { Parent, } -struct ClassObjectTemplate { - /// The width the compiled evaluation allocates its class object with. - field_count: u32, - static_field_mask: u32, - has_parent: bool, - final_shape: u32, - fills: Rc<[Fill]>, - /// Recorded additions of an internal own key (`InternalKey`) to a class - /// object of this template: from shape, key, to shape, slot. Every id is - /// a retained record. - edges: Vec<(u32, InternalKey, u32, u32)>, -} - /// The most internal-key transitions one template records. const MAX_TEMPLATE_EDGES: usize = 8; +/// A per-evaluation class template's own record: an image static codegen +/// emits once per template (`@perry_ctpl.`) and hands to the template's +/// evaluation site, and whose address the template's vtable entry carries +/// ([`js_register_class_template_cell`]) for the runtime paths that start from +/// a class object. Codegen gives it its length in words (word 0) and nothing +/// else; this module owns the rest of the layout. +/// +/// It memoizes what the template's first evaluation reached: the final +/// ShapeIds of its class object and prototype, how each of their slots is +/// filled, and the internal-key transitions between them. ShapeIds name one +/// agent's own shape records, so the cell answers only the thread that +/// recorded it (its owner token). Any other thread takes the ordinary path, +/// which is correct and only slower. +#[derive(Clone, Copy)] +pub(crate) struct TemplateCell(*const AtomicU64); + +/// The cell's length in words, written by codegen. +const W_LEN: usize = 0; +/// The token of the thread that owns the cell's ShapeIds; 0 while unclaimed. +const W_OWNER: usize = 1; +/// The class object's final ShapeId; 0 when not recorded. +const W_CLASS_SHAPE: usize = 2; +/// `field_count | static_field_mask << 32 | has_parent << 40 | fills << 48`. +const W_CLASS_FACTS: usize = 3; +/// Whether the class object's shape was settled (recorded or refused). +const W_CLASS_SETTLED: usize = 4; +/// The prototype object's final ShapeId; 0 when not recorded. +const W_PROTO_SHAPE: usize = 5; +/// `field_count | fills << 32`. +const W_PROTO_FACTS: usize = 6; +/// The [[Prototype]] identity the prototype's final shape names. +const W_PROTO_ID: usize = 7; +/// `MAX_TEMPLATE_EDGES` pairs: `from | to << 32`, `slot | key << 32` (key is +/// `InternalKey as u64 + 1`, 0 for an empty pair). +const W_EDGES: usize = 8; +/// The class fills (two words each: tag, value), then the prototype fills. +const W_FILLS: usize = W_EDGES + 2 * MAX_TEMPLATE_EDGES; + +/// Fill tags. +const TAG_BITS: u64 = 1; +const TAG_NAME: u64 = 2; +const TAG_METHOD: u64 = 3; +const TAG_PARENT: u64 = 4; +const TAG_CONSTRUCTOR: u64 = 5; + +#[thread_local] +static THREAD_TOKEN: std::cell::Cell = std::cell::Cell::new(0); +static NEXT_THREAD_TOKEN: AtomicU64 = AtomicU64::new(1); + +/// This thread's token: unique for the process's life, never 0. +#[inline] +fn thread_token() -> u64 { + let token = THREAD_TOKEN.get(); + if token != 0 { + return token; + } + let token = NEXT_THREAD_TOKEN.fetch_add(1, Ordering::Relaxed); + THREAD_TOKEN.set(token); + token +} + +impl TemplateCell { + /// The cell at `ptr`, when it is one: non-null, with a length that holds + /// its fixed words. + pub(crate) unsafe fn from_ptr(ptr: *const u64) -> Option { + if ptr.is_null() || (ptr as usize) % std::mem::align_of::() != 0 { + return None; + } + let cell = TemplateCell(ptr as *const AtomicU64); + (cell.len() >= W_FILLS).then_some(cell) + } + + #[inline] + unsafe fn word(self, i: usize) -> &'static AtomicU64 { + &*self.0.add(i) + } + + #[inline] + unsafe fn len(self) -> usize { + self.word(W_LEN).load(Ordering::Relaxed) as usize + } + + #[inline] + unsafe fn get(self, i: usize) -> u64 { + self.word(i).load(Ordering::Relaxed) + } + + #[inline] + unsafe fn set(self, i: usize, v: u64) { + self.word(i).store(v, Ordering::Relaxed) + } + + /// Does this thread own the cell's ShapeIds? Every word but the owner is + /// read and written only by its owner thread. + #[inline] + unsafe fn owned(self) -> bool { + self.word(W_OWNER).load(Ordering::Acquire) == thread_token() + } + + /// Claim the cell for this thread, or report whether this thread already + /// owns it. + unsafe fn claim(self) -> bool { + let token = thread_token(); + match self + .word(W_OWNER) + .compare_exchange(0, token, Ordering::AcqRel, Ordering::Acquire) + { + Ok(_) => true, + Err(owner) => owner == token, + } + } + + /// The class fill at `i` (of `n` recorded). + #[inline] + unsafe fn class_fill(self, i: usize) -> Fill { + let (tag, value) = (self.get(W_FILLS + 2 * i), self.get(W_FILLS + 2 * i + 1)); + match tag { + TAG_NAME => Fill::Name, + TAG_METHOD => Fill::Method(value as usize), + TAG_PARENT => Fill::Parent, + _ => Fill::Bits(value), + } + } + + /// Where the prototype fills start: after the class fills. + #[inline] + unsafe fn proto_fills_base(self) -> usize { + W_FILLS + 2 * ((self.get(W_CLASS_FACTS) >> 48) as usize) + } + + #[inline] + unsafe fn proto_fill(self, i: usize) -> ProtoFill { + let base = self.proto_fills_base(); + match self.get(base + 2 * i) { + TAG_CONSTRUCTOR => ProtoFill::Constructor, + _ => ProtoFill::Method(self.get(base + 2 * i + 1) as usize), + } + } + + /// The recorded class-object template: final shape, fill count and + /// whether it has a parent, when this thread owns one recorded for + /// `field_count` and `static_field_mask`. + #[inline] + unsafe fn class_template( + self, + field_count: u32, + static_field_mask: u32, + ) -> Option<(u32, usize, bool)> { + if !self.owned() { + return None; + } + let shape = self.get(W_CLASS_SHAPE) as u32; + let facts = self.get(W_CLASS_FACTS); + (shape != 0 + && facts as u32 == field_count + && (facts >> 32) as u8 as u32 == static_field_mask) + .then_some((shape, (facts >> 48) as usize, facts & (1 << 40) != 0)) + } + + /// The recorded prototype template: field count, final shape, fill count + /// and [[Prototype]] identity, when this thread owns one. + #[inline] + unsafe fn proto_template(self) -> Option<(u32, u32, usize, u64)> { + if !self.owned() { + return None; + } + let shape = self.get(W_PROTO_SHAPE) as u32; + let facts = self.get(W_PROTO_FACTS); + (shape != 0).then(|| { + ( + facts as u32, + shape, + (facts >> 32) as usize, + self.get(W_PROTO_ID), + ) + }) + } +} + +/// Register `cell`, template `class_id`'s template cell, on its vtable entry, +/// so a path that starts from one of its class objects finds it. Codegen emits +/// one call per per-evaluation template at module init. +#[no_mangle] +pub extern "C" fn js_register_class_template_cell(class_id: i64, cell: i64) { + if class_id <= 0 || class_id > u32::MAX as i64 || cell == 0 { + return; + } + super::super::class_registry::class_set_template_cell(class_id as u32, cell as usize); +} + +/// Template `class_id`'s template cell, if codegen registered one. +#[inline] +fn template_cell_of(class_id: u32) -> Option { + let ptr = super::super::class_registry::class_template_cell(class_id)?; + unsafe { TemplateCell::from_ptr(ptr as *const u64) } +} + /// An own key the runtime adds to a per-evaluation class object after its /// members: the captured environment (`__perry_ctor_caps`) and the /// evaluation's prototype object (`#`). @@ -102,13 +288,6 @@ fn note_template_hit(prototype: bool) { }); } -thread_local! { - /// This agent's template shapes, by template class id. ShapeIds name an - /// agent's own records, so the memo is the agent's too. - static TEMPLATES: std::cell::RefCell> = - std::cell::RefCell::new(crate::fast_hash::new_ptr_hash_map()); -} - /// The own-property key of the evaluation's captured environment. pub(crate) const CTOR_CAPS_KEY: &[u8] = b"__perry_ctor_caps"; @@ -148,38 +327,38 @@ pub(crate) unsafe fn static_method_value_runs( /// its own `length`, `name` and static methods and its pinned parent. /// `static_field_mask` (bit 0 `length`, bit 1 `name`) names the static fields /// that take over an intrinsic key. +/// `cell` is the template's cell (`@perry_ctpl.`); null takes the +/// ordinary path. #[no_mangle] pub extern "C" fn js_class_evaluation_object( template_class_id: u32, field_count: u32, static_field_mask: u32, + cell: *const u64, ) -> i64 { - let template = TEMPLATES.with(|t| { - t.borrow() - .get(&template_class_id) - .filter(|t| t.field_count == field_count && t.static_field_mask == static_field_mask) - .map(|t| (t.final_shape, t.fills.clone(), t.has_parent)) - }); + let cell = unsafe { TemplateCell::from_ptr(cell) }; + let template = cell.and_then(|c| unsafe { c.class_template(field_count, static_field_mask) }); // The template stash, deliberately: this records the heritage the // `RegisterClassParentDynamic` call immediately preceding the evaluation // evaluated for THIS evaluation. `js_get_dynamic_parent_value`'s // active-replay override would answer with an enclosing constructor // replay's parent when a factory is re-entered from inside a constructor // body. A template recorded without heritage has none to read. - let has_parent = template.as_ref().is_none_or(|t| t.2); + let has_parent = template.is_none_or(|t| t.2); let parent = if has_parent { crate::object::parent_static::template_dynamic_parent_value(template_class_id) } else { f64::from_bits(crate::value::TAG_UNDEFINED) }; - if let Some((final_shape, fills, has_parent)) = template { + if let (Some(cell), Some((final_shape, fills, has_parent))) = (cell, template) { if has_parent == (parent.to_bits() != crate::value::TAG_UNDEFINED) { return unsafe { class_object_in_template_shape( template_class_id, field_count, final_shape, - &fills, + cell, + fills, parent, ) } as i64; @@ -194,13 +373,16 @@ pub extern "C" fn js_class_evaluation_object( static_field_mask, parent, &|obj, parent| { - record_class_object_template( - obj, - template_class_id, - field_count, - static_field_mask, - parent, - ) + if let Some(cell) = cell { + record_class_object_template( + cell, + obj, + template_class_id, + field_count, + static_field_mask, + parent, + ) + } }, ) as i64 } @@ -212,7 +394,8 @@ unsafe fn class_object_in_template_shape( class_id: u32, field_count: u32, final_shape: u32, - fills: &[Fill], + cell: TemplateCell, + fills: usize, parent: f64, ) -> *mut ObjectHeader { #[cfg(test)] @@ -231,8 +414,8 @@ unsafe fn class_object_in_template_shape( crate::object::field_get_set::note_private_template_evaluated(class_id); crate::object::class_registry::class_object_value_root_store(class_id, obj); let class = scope.root_raw_mut_ptr(obj); - for (slot, fill) in fills.iter().enumerate() { - let bits = match *fill { + for slot in 0..fills { + let bits = match cell.class_fill(slot) { Fill::Bits(bits) => bits, Fill::Name => super::super::class_value::intrinsic_own_data_value(class_id, "name") .map_or(crate::value::TAG_UNDEFINED, f64::to_bits), @@ -262,15 +445,19 @@ unsafe fn class_object_in_template_shape( /// as template `class_id`'s, if its keys are exactly the template's, each in /// its inline slot, and its lanes are all `Any`. unsafe fn record_class_object_template( + cell: TemplateCell, obj: *mut ObjectHeader, class_id: u32, field_count: u32, static_field_mask: u32, parent: f64, ) { - if TEMPLATES.with(|t| t.borrow().contains_key(&class_id)) { + // The first evaluation on the thread that claims the cell settles it: + // recorded, or refused for good. + if !cell.claim() || cell.get(W_CLASS_SETTLED) != 0 { return; } + cell.set(W_CLASS_SETTLED, 1); let Some(d) = crate::object::shapes::object_shape_descriptor(obj) else { return; }; @@ -281,6 +468,9 @@ unsafe fn record_class_object_template( || d.rep != crate::object::field_rep::REP_ANY || d.live_inline_slot_count < d.logical_key_count || count > (field_count as usize).max(crate::object::INLINE_SLOT_FLOOR) + || count >= 1 << 16 + || W_FILLS + 2 * count > cell.len() + || static_field_mask > 0xff || crate::object::shapes::shape_object_kind_by_id(final_shape) != Some(crate::object::shapes::ShapeObjectKind::Class) { @@ -336,19 +526,24 @@ unsafe fn record_class_object_template( // The record stays this agent's for its life, so the template never // stamps an id that names other facts. crate::object::shapes::note_external_shape_carrier(Some(d)); - TEMPLATES.with(|t| { - t.borrow_mut().insert( - class_id, - ClassObjectTemplate { - field_count, - static_field_mask, - has_parent, - final_shape, - fills: fills.into(), - edges: Vec::new(), - }, - ) - }); + for (i, fill) in fills.iter().enumerate() { + let (tag, value) = match *fill { + Fill::Bits(bits) => (TAG_BITS, bits), + Fill::Name => (TAG_NAME, 0), + Fill::Method(code) => (TAG_METHOD, code as u64), + Fill::Parent => (TAG_PARENT, 0), + }; + cell.set(W_FILLS + 2 * i, tag); + cell.set(W_FILLS + 2 * i + 1, value); + } + cell.set( + W_CLASS_FACTS, + field_count as u64 + | (static_field_mask as u64) << 32 + | (has_parent as u64) << 40 + | (count as u64) << 48, + ); + cell.set(W_CLASS_SHAPE, final_shape as u64); } /// Add internal own key `key` with `value` to class object `obj`. From a shape @@ -360,20 +555,26 @@ pub(crate) unsafe fn class_object_add_internal( obj: *mut ObjectHeader, key: InternalKey, value: f64, + cell: Option, ) { - let class_id = (*obj).class_id; let from = crate::object::shapes::object_shape_id(obj); - let edge = TEMPLATES.with(|t| { - t.borrow().get(&class_id).map(|t| { - ( - t.edges - .iter() - .find(|e| e.0 == from && e.1 == key) - .map(|e| (e.2, e.3)), - t.edges.len() < MAX_TEMPLATE_EDGES, - ) - }) - }); + let key_word = key as u64 + 1; + // The recorded transition from `from` by `key`, and the first free pair + // when there is none, of a cell this thread owns. + let edge = cell + .filter(|c| c.owned() && c.get(W_CLASS_SHAPE) != 0) + .map(|c| { + let mut free = None; + for i in 0..MAX_TEMPLATE_EDGES { + let (ids, at) = (c.get(W_EDGES + 2 * i), c.get(W_EDGES + 2 * i + 1)); + if at >> 32 == 0 { + free.get_or_insert(i); + } else if ids as u32 == from && at >> 32 == key_word { + return (Some(((ids >> 32) as u32, at as u32)), free); + } + } + (None, free) + }); if let Some((Some((to, slot)), _)) = edge { // `obj` carries the recorded predecessor, so it is exactly as wide as // the object the transition was recorded on: `slot` is inline. @@ -389,7 +590,10 @@ pub(crate) unsafe fn class_object_add_internal( class.with_mut_ptr::(|obj| { crate::object::js_object_set_field_by_name(obj, key_str, value.get_nanbox_f64()) }); - if edge != Some((None, true)) || from == 0 { + let (Some(cell), Some((None, Some(free)))) = (cell, edge) else { + return; + }; + if from == 0 { return; } class.with_mut_ptr::(|obj| { @@ -415,22 +619,38 @@ pub(crate) unsafe fn class_object_add_internal( crate::object::shapes::shape_descriptor_by_id(from), ); crate::object::shapes::note_external_shape_carrier(Some(d)); - TEMPLATES.with(|t| { - if let Some(t) = t.borrow_mut().get_mut(&class_id) { - t.edges.push((from, key, to, slot)); - } - }); + cell.set(W_EDGES + 2 * free, from as u64 | (to as u64) << 32); + cell.set(W_EDGES + 2 * free + 1, slot as u64 | key_word << 32); }); } /// `__perry_ctor_caps` of a per-evaluation class object (`ClassExprFresh`): -/// the captured environment its constructor replays with. +/// the captured environment its constructor replays with. `cell` is the +/// template's cell. #[no_mangle] -pub extern "C" fn js_class_object_set_ctor_caps(obj: i64, caps: f64) { +pub extern "C" fn js_class_object_set_ctor_caps(obj: i64, caps: f64, cell: *const u64) { if obj == 0 { return; } - unsafe { class_object_add_internal(obj as *mut ObjectHeader, InternalKey::CtorCaps, caps) } + unsafe { + class_object_add_internal( + obj as *mut ObjectHeader, + InternalKey::CtorCaps, + caps, + TemplateCell::from_ptr(cell), + ) + } +} + +/// [`class_object_add_internal`] for a class object of template `class_id`, +/// with the template's registered cell. +pub(crate) unsafe fn class_object_add_internal_for( + obj: *mut ObjectHeader, + class_id: u32, + key: InternalKey, + value: f64, +) { + class_object_add_internal(obj, key, value, template_cell_of(class_id)) } /// How one slot of a template's final prototype shape is filled. @@ -443,21 +663,6 @@ enum ProtoFill { Method(usize), } -struct PrototypeTemplate { - /// The width the prototype object is allocated with. - field_count: u32, - /// The [[Prototype]] identity the final shape names. - proto_id: u64, - final_shape: u32, - fills: Rc<[ProtoFill]>, -} - -thread_local! { - /// This agent's template prototype shapes, by template class id. - static PROTOTYPES: std::cell::RefCell> = - std::cell::RefCell::new(crate::fast_hash::new_ptr_hash_map()); -} - /// The function object one evaluation's prototype holds for method `name` of /// template `class_id`: it runs the method's closure-convention entry, and its /// one capture is its home, class object `class`. `None` when the template @@ -483,12 +688,11 @@ pub(crate) unsafe fn prototype_from_template( class_id: u32, parent_proto: u64, ) -> Option<*mut ObjectHeader> { - let (field_count, final_shape, fills) = PROTOTYPES.with(|t| { - let t = t.borrow(); - let t = t.get(&class_id)?; - (crate::object::shapes::stable_linked_proto_id(class_id, parent_proto) == Some(t.proto_id)) - .then(|| (t.field_count, t.final_shape, t.fills.clone())) - })?; + let cell = template_cell_of(class_id)?; + let (field_count, final_shape, fills, proto_id) = cell.proto_template()?; + if crate::object::shapes::stable_linked_proto_id(class_id, parent_proto) != Some(proto_id) { + return None; + } #[cfg(test)] note_template_hit(true); let scope = crate::gc::RuntimeHandleScope::new(); @@ -522,8 +726,8 @@ pub(crate) unsafe fn prototype_from_template( crate::object::shapes::stamp_object_shape_id_with_carrier_note(proto, final_shape); crate::object::descriptor_state::note_attrs_born_with_keys(proto as usize); }); - for (slot, fill) in fills.iter().enumerate() { - let bits = match *fill { + for slot in 0..fills { + let bits = match cell.proto_fill(slot) { ProtoFill::Constructor => class .with_const_ptr::(|c| crate::value::js_nanbox_pointer(c as i64)), ProtoFill::Method(code) => { @@ -553,7 +757,12 @@ pub(crate) unsafe fn record_prototype_template( field_count: u32, parent_proto: u64, ) { - if PROTOTYPES.with(|t| t.borrow().contains_key(&class_id)) { + let Some(cell) = template_cell_of(class_id) else { + return; + }; + // Only on top of this thread's class-object template: the prototype fills + // follow its fills in the cell. + if !cell.owned() || cell.get(W_CLASS_SETTLED) == 0 || cell.get(W_PROTO_SHAPE) != 0 { return; } let Some(proto_id) = crate::object::shapes::stable_linked_proto_id(class_id, parent_proto) @@ -581,6 +790,8 @@ pub(crate) unsafe fn record_prototype_template( | crate::object::OBJECT_META_FLAG_CLASS_EVALUATION_PROTO) != 0 || (*meta).spill != 0 + || u32::try_from(count).is_err() + || cell.proto_fills_base() + 2 * count > cell.len() { return; } @@ -609,17 +820,18 @@ pub(crate) unsafe fn record_prototype_template( fills.push(fill); } crate::object::shapes::note_external_shape_carrier(Some(d)); - PROTOTYPES.with(|t| { - t.borrow_mut().insert( - class_id, - PrototypeTemplate { - field_count, - proto_id, - final_shape, - fills: fills.into(), - }, - ) - }); + let base = cell.proto_fills_base(); + for (i, fill) in fills.iter().enumerate() { + let (tag, value) = match *fill { + ProtoFill::Constructor => (TAG_CONSTRUCTOR, 0), + ProtoFill::Method(code) => (TAG_METHOD, code as u64), + }; + cell.set(base + 2 * i, tag); + cell.set(base + 2 * i + 1, value); + } + cell.set(W_PROTO_FACTS, field_count as u64 | (count as u64) << 32); + cell.set(W_PROTO_ID, proto_id); + cell.set(W_PROTO_SHAPE, final_shape as u64); } /// Is `obj` an evaluation prototype of template `class_id` built with the @@ -660,9 +872,9 @@ pub(crate) unsafe fn evaluation_chain_lost_method( return false; } let class_id = (*obj).class_id; - let template = PROTOTYPES - .with(|t| t.borrow().get(&class_id).map(|t| t.final_shape)) - .and_then(crate::object::shapes::shape_descriptor_by_id); + let template = template_cell_of(class_id) + .and_then(|c| c.proto_template()) + .and_then(|t| crate::object::shapes::shape_descriptor_by_id(t.1)); if let Some(template) = template { // The template's prototype keys, all still there: marking the // prototype (its first instance) restamps its shape but keeps them. diff --git a/crates/perry-runtime/src/object/field_get_set/class_object_template_tests.rs b/crates/perry-runtime/src/object/field_get_set/class_object_template_tests.rs index e1bea2fec9..aa17718ae2 100644 --- a/crates/perry-runtime/src/object/field_get_set/class_object_template_tests.rs +++ b/crates/perry-runtime/src/object/field_get_set/class_object_template_tests.rs @@ -18,6 +18,13 @@ extern "C" fn method(_this: f64) -> f64 { 0.0 } +/// A template cell as codegen emits it: `words` long, zero but its length. +fn cell(words: usize) -> *mut u64 { + let mut cell = vec![0u64; words].into_boxed_slice(); + cell[0] = words as u64; + Box::leak(cell).as_mut_ptr() +} + unsafe fn slot(obj: *const ObjectHeader, name: &[u8]) -> u64 { super::super::class_registry::class_object_own_field_bytes(obj, name) .expect("own data property") @@ -67,10 +74,14 @@ fn later_evaluations_are_born_in_the_template_shapes() { info as i64, ); } + let cell = cell(64); + js_register_class_template_cell(cid as i64, cell as i64); let scope = crate::gc::RuntimeHandleScope::new(); let before = template_hits(); let classes: Vec<_> = (0..3) - .map(|_| scope.root_raw_mut_ptr(js_class_evaluation_object(cid, 6, 0) as *mut ObjectHeader)) + .map(|_| { + scope.root_raw_mut_ptr(js_class_evaluation_object(cid, 6, 0, cell) as *mut ObjectHeader) + }) .collect(); let protos: Vec<_> = classes .iter() @@ -151,3 +162,42 @@ fn later_evaluations_are_born_in_the_template_shapes() { ); } } + +/// The template's memo lives in its own cell, and only the thread that +/// recorded it reads it: ShapeIds name one agent's shape records, so another +/// thread must take the ordinary path. A cell too short for a record refuses +/// to record rather than write past its end. +#[test] +fn a_template_cell_answers_only_its_recording_thread() { + unsafe { + let words = cell(W_FILLS + 2); + let c = TemplateCell::from_ptr(words).expect("a cell"); + assert!( + TemplateCell::from_ptr(cell(W_FILLS - 1)).is_none(), + "too short for its fixed words" + ); + assert!(TemplateCell::from_ptr(std::ptr::null()).is_none()); + assert!(c.claim(), "the first thread claims the cell"); + assert!(c.owned() && c.claim(), "and keeps it"); + c.set(W_CLASS_SHAPE, 7); + c.set(W_CLASS_FACTS, 6 | 1 << 48); + assert_eq!(c.class_template(6, 0), Some((7, 1, false))); + assert_eq!( + c.class_template(5, 0), + None, + "another width is another site" + ); + let addr = words as usize; + let other = std::thread::spawn(move || { + let c = TemplateCell::from_ptr(addr as *const u64).unwrap(); + (c.owned(), c.claim(), c.class_template(6, 0).is_some()) + }) + .join() + .unwrap(); + assert_eq!( + other, + (false, false, false), + "another thread never reads the memo" + ); + } +} diff --git a/scripts/registry_lifetime_allowlist.json b/scripts/registry_lifetime_allowlist.json index 2376af4f9e..28cf459c7e 100644 --- a/scripts/registry_lifetime_allowlist.json +++ b/scripts/registry_lifetime_allowlist.json @@ -547,18 +547,6 @@ "name": "UNTRANSFERABLE_OBJECTS", "verdict": "open_leak", "why": "NaN-box bits of each object passed to markAsUntransferable: one per marked object, never unmarked" - }, - { - "file": "crates/perry-runtime/src/object/field_get_set/class_object_template.rs", - "name": "TEMPLATES", - "verdict": "bounded_by_program", - "why": "Per agent, keyed by per-evaluation class template id: the final class-object ShapeId and slot fills, recorded once per template (the records are external carriers)" - }, - { - "file": "crates/perry-runtime/src/object/field_get_set/class_object_template.rs", - "name": "PROTOTYPES", - "verdict": "bounded_by_program", - "why": "Per agent, keyed by per-evaluation class template id: the final prototype ShapeId and slot fills, recorded once per template (the record is an external carrier)" } ] } From ac25079e7ea651716e5703904553d9dfbfb0e4f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 3 Oct 2026 13:27:13 +0000 Subject: [PATCH 2/5] runtime: a fresh class converts and lists its keys like node after edits Three gaps in #11780's fresh class objects, each shown by a new fresh_class_object_semantics line that main (a739f6c5dd) gets wrong: - str-own-undefined: an own toString holding undefined was treated as absent, so String(C) returned the class source instead of throwing a TypeError (main before #11780 threw). Presence now decides, whatever the property holds. - str-deleted-static: a static toString the template declares still kept the source text out after delete C.toString removed it from this evaluation, so String(C) gave [object Function]. The class object's own state decides (class_object_registry_serves_static), not the template's declaration. - own-index: getOwnPropertyNames put prototype before integer keys such as a static method named 0. --- .../src/object/class_registry.rs | 16 +++++------ .../object/class_registry/parent_static.rs | 28 +++++++++++++++++++ .../perry-runtime/src/object/descriptors.rs | 9 +++--- .../field_get_set/class_object_props.rs | 16 +++++------ .../fresh_class_object_semantics/expected.txt | 3 ++ .../fresh_class_object_semantics/main.ts | 13 +++++++++ 6 files changed, 65 insertions(+), 20 deletions(-) diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index d25310e7a8..f18b7e5d79 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -231,14 +231,14 @@ pub(crate) use parent_static::{ class_dynamic_static_accessor_getter_value, class_has_instance_getter, class_has_own_static_method, class_has_own_symbol_member, class_has_symbol_member_in_chain, class_instance_setter_apply, class_method_bind_length, class_object_own_field_bytes, - class_object_pinned_parent, class_own_static_method_code, class_own_static_method_entry, - class_own_symbol_accessor_ptrs, class_own_symbol_member_keys, class_own_symbol_method, - class_private_instance_getter_value, class_private_instance_setter_apply, - class_static_accessor_getter_value, class_static_accessor_setter_apply, - class_symbol_getter_value, class_symbol_setter_apply, dynamic_value_class_id, - get_parent_class_id, instance_chain_parent_class_id, lookup_class_symbol_method_in_chain, - lookup_static_method_in_chain, lookup_static_method_owner, register_class, - register_class_dynamic_static_accessor, static_accessor_in_chain, + class_object_owns_key_bytes, class_object_pinned_parent, class_own_static_method_code, + class_own_static_method_entry, class_own_symbol_accessor_ptrs, class_own_symbol_member_keys, + class_own_symbol_method, class_private_instance_getter_value, + class_private_instance_setter_apply, class_static_accessor_getter_value, + class_static_accessor_setter_apply, class_symbol_getter_value, class_symbol_setter_apply, + dynamic_value_class_id, get_parent_class_id, instance_chain_parent_class_id, + lookup_class_symbol_method_in_chain, lookup_static_method_in_chain, lookup_static_method_owner, + register_class, register_class_dynamic_static_accessor, static_accessor_in_chain, }; pub use parent_static::{ is_class_object_ptr, is_class_object_value, is_registered_class_prototype_object, 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 d1e9181a3a..805ecf7f08 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -470,6 +470,34 @@ pub(crate) fn class_object_own_field_bytes( None } +/// Does class object `obj` have an own property `want`, whatever it holds (a +/// data value, `undefined` included, or an accessor)? +pub(crate) fn class_object_owns_key_bytes( + obj: *const crate::object::ObjectHeader, + want: &[u8], +) -> bool { + if obj.is_null() + || !crate::value::addr_class::is_above_handle_band(obj as usize) + || !crate::object::is_valid_obj_ptr(obj as *const u8) + { + return false; + } + unsafe { + let keys_view = crate::object::object_keys(obj); + let keys = keys_view.arr(); + if keys.is_null() { + return false; + } + let (slots, slot_len) = crate::object::keys_array_dense_slots(keys); + (0..(keys_view.count() as usize).min(slot_len)).any(|i| { + crate::string::js_string_key_matches_bytes( + crate::JSValue::from_bits((*slots.add(i)).to_bits()), + want, + ) + }) + } +} + /// Read back the parent constructor value stashed at class-definition time by /// `js_register_class_parent_dynamic` (see `CLASS_DYNAMIC_PARENT_VALUE`). /// `super()` in a `class X extends ` body uses this so the diff --git a/crates/perry-runtime/src/object/descriptors.rs b/crates/perry-runtime/src/object/descriptors.rs index 69b316ba16..1e42444c0b 100644 --- a/crates/perry-runtime/src/object/descriptors.rs +++ b/crates/perry-runtime/src/object/descriptors.rs @@ -1009,9 +1009,10 @@ pub extern "C" fn js_object_get_own_property_names(obj_value: f64) -> f64 { js_object_get_own_property_names_shape(obj_value) } -/// `names` (a class object's stored own keys, in creation order) with -/// `prototype` inserted where ClassDefinitionEvaluation created it: after the -/// leading `length` / `name` that are still the object's first keys. +/// `names` (a class object's stored own keys in property order: integer +/// indices first, then strings in creation order) with `prototype` inserted +/// where ClassDefinitionEvaluation created it among the strings: after the +/// leading `length` / `name` that are still the object's first string keys. fn class_object_names_with_prototype(names: f64) -> f64 { let scope = crate::gc::RuntimeHandleScope::new(); let names = scope.root_nanbox_f64(names); @@ -1032,7 +1033,7 @@ fn class_object_names_with_prototype(names: f64) -> f64 { } let at = out .iter() - .position(|n| n != "length" && n != "name") + .position(|n| n != "length" && n != "name" && property_name_array_index(n).is_none()) .unwrap_or(out.len()); out.insert(at, "prototype".to_string()); let result = crate::array::js_array_alloc(out.len() as u32); 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 1742dff6f5..7e984702ca 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 @@ -443,18 +443,18 @@ pub(crate) fn class_object_source_text(value: f64) -> Option { } /// The text `String(C)` / `` `${C}` `` produce for the class object `value` -/// when no `toString` of the program's is in the way, else `None`. A static -/// `toString` of this evaluation or of a class it inherits from answers -/// instead (the caller's ordinary conversion finds and runs it). +/// when no `toString` of the program's is in the way, else `None`. An own +/// `toString` property of the object, whatever it holds (a non-callable one +/// makes the conversion throw), and a static `toString` the object or a class +/// it inherits from still declares answer instead: the caller's ordinary +/// conversion finds them. A declared static `toString` the evaluation no +/// longer holds is not in the way. pub(crate) fn class_object_default_to_string(value: f64) -> Option { let text = class_object_source_text(value)?; let obj = JSValue::from_bits(value.to_bits()).as_pointer::(); // SAFETY: a live class object (above). - let class_id = unsafe { (*obj).class_id }; - let own = super::super::class_registry::class_object_own_field_bytes(obj, b"toString") - .is_some_and(|v| !JSValue::from_bits(v.to_bits()).is_undefined()); - if own - || super::super::class_registry::lookup_static_method_owner(class_id, "toString").is_some() + if super::super::class_registry::class_object_owns_key_bytes(obj, b"toString") + || unsafe { class_object_registry_serves_static(obj, "toString") } { return None; } diff --git a/tests/fixtures/fresh_class_object_semantics/expected.txt b/tests/fixtures/fresh_class_object_semantics/expected.txt index 193dc1d798..ef2a30536c 100644 --- a/tests/fixtures/fresh_class_object_semantics/expected.txt +++ b/tests/fixtures/fresh_class_object_semantics/expected.txt @@ -25,6 +25,9 @@ str-method true true str-template true true str-proto true str-override custom1 custom1 custom1 +str-deleted-static true d2 +str-own-undefined TypeError +own-index ["0","length","name","prototype","s"] ["0","length","name","prototype","s"] tpl-own ["length","name","prototype","s","who"] ["constructor","m","n"] tpl-statics s1 s2 s3 false false s 0 tpl-detached s2 s3 s2 s3 s3 diff --git a/tests/fixtures/fresh_class_object_semantics/main.ts b/tests/fixtures/fresh_class_object_semantics/main.ts index 2779676199..409c5033bd 100644 --- a/tests/fixtures/fresh_class_object_semantics/main.ts +++ b/tests/fixtures/fresh_class_object_semantics/main.ts @@ -122,6 +122,19 @@ function ov(tag: string) { } const O1: any = ov("1"); console.log("str-override", String(O1), O1.toString(), `${O1}`); +// A static `toString` this evaluation no longer holds is not in the way; an +// own `toString` that is not callable makes the conversion throw. +function sd(i: number) { class D { v = i; static toString() { return "d" + i; } } return D; } +const D1: any = sd(1), D2: any = sd(2); +delete D1.toString; +console.log("str-deleted-static", String(D1) === Function.prototype.toString.call(D1), String(D2)); +function su(i: number) { class U { v = i; } return U; } +const U1: any = su(1); +U1.toString = undefined; +try { console.log("str-own-undefined", String(U1)); } catch (e) { console.log("str-own-undefined", (e as any).constructor.name); } +// Integer keys come first among a class object's own keys, before `prototype`. +function ix(i: number) { class X { v = i; static 0() { return 0; } static s() { return i; } } return X; } +console.log("own-index", JSON.stringify(Object.getOwnPropertyNames(ix(1))), JSON.stringify(Object.getOwnPropertyNames(ix(2)))); // ---- tpl: evaluations after the first are born in the template shapes ---- function tp(tag: string, base: any) { const t = tag; From 2920ec4c1da6a1d931c7ae75c7da2c507e4efe0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 3 Oct 2026 13:27:13 +0000 Subject: [PATCH 3/5] changelog: fresh class conversion fixes and the template cell --- changelog.d/PLACEHOLDER-fresh-class-followups.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 changelog.d/PLACEHOLDER-fresh-class-followups.md diff --git a/changelog.d/PLACEHOLDER-fresh-class-followups.md b/changelog.d/PLACEHOLDER-fresh-class-followups.md new file mode 100644 index 0000000000..097c5fdd9a --- /dev/null +++ b/changelog.d/PLACEHOLDER-fresh-class-followups.md @@ -0,0 +1,13 @@ +Fixed three conversion and reflection gaps in classes created per evaluation +(class expressions in functions, and declarations whose evaluation has its own +environment), and moved their shape memo out of a table. + +`String(C)` on such a class now returns the class source after `delete +C.toString` removed a static `toString` the class declared, instead of +`[object Function]`. An own `toString` set to a non-callable value such as +`undefined` makes `String(C)` throw a `TypeError` again, as in node, instead of +returning the class source. `Object.getOwnPropertyNames(C)` lists integer keys +such as a static method named `0` before `prototype`, as in node. + +What a class remembers to build later evaluations directly in its shapes now +lives in the class's own record instead of a table keyed by class. From 1bea17f628dc8784c8a567af57246a872c1a1621 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 3 Oct 2026 14:46:24 +0000 Subject: [PATCH 4/5] gc_call_effects: resync the three tables with the archives (template cell row, main drift) --- crates/perry-codegen/src/gc_effects/linux-x86_64.tsv | 1 + crates/perry-codegen/src/gc_effects/macos-aarch64.tsv | 6 +++++- crates/perry-codegen/src/gc_effects/windows-x86_64.tsv | 4 ++++ 3 files changed, 10 insertions(+), 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 a92af889b4..e355b6d00b 100644 --- a/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv +++ b/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv @@ -2802,6 +2802,7 @@ js_register_path_init Reenters js_register_path_module Reenters js_register_path_module_partial Reenters js_register_prototype_method Reenters +js_register_readable_handle_predicate Leaf js_register_set_text_handler Reenters js_register_show_toast_handler Reenters js_register_stdin_flow_ops Leaf diff --git a/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv b/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv index 03c4feaaac..cf79a9c1c0 100644 --- a/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv +++ b/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv @@ -594,7 +594,7 @@ 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_entry_enter_home Reenters +js_class_method_entry_enter_home ThrowOnly js_class_method_entry_leave Leaf js_class_method_snapshot_bind Reenters js_class_object_refresh_capture_values Reenters @@ -2110,6 +2110,8 @@ js_object_delete_field Reenters js_object_delete_field_value Reenters js_object_entries Reenters js_object_entries_value Reenters +js_object_final_shape_id_for_class_keys_static_constfn Reenters +js_object_finalize_constfn_static Reenters js_object_free Leaf js_object_freeze Reenters js_object_from_entries Reenters @@ -2800,6 +2802,7 @@ js_register_path_init Leaf js_register_path_module ThrowOnly js_register_path_module_partial ThrowOnly js_register_prototype_method Reenters +js_register_readable_handle_predicate Leaf js_register_set_text_handler Reenters js_register_show_toast_handler Reenters js_register_stdin_flow_ops Leaf @@ -2997,6 +3000,7 @@ js_shape_ordinary_inline_slot_for_key Reenters js_shape_register_static_seed Leaf js_shape_run_static_seed Reenters js_shape_seed_plain Reenters +js_shape_seed_plain_constfn Reenters js_shared_array_buffer_new ThrowOnly js_shared_array_buffer_new_value Reenters js_sharp_blur Reenters 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 f3c41302f1..6516043d7e 100644 --- a/crates/perry-codegen/src/gc_effects/windows-x86_64.tsv +++ b/crates/perry-codegen/src/gc_effects/windows-x86_64.tsv @@ -2110,6 +2110,8 @@ js_object_delete_field Reenters js_object_delete_field_value Reenters js_object_entries Reenters js_object_entries_value Reenters +js_object_final_shape_id_for_class_keys_static_constfn Reenters +js_object_finalize_constfn_static Reenters js_object_free Leaf js_object_freeze Reenters js_object_from_entries Reenters @@ -2800,6 +2802,7 @@ js_register_path_init Leaf js_register_path_module Reenters js_register_path_module_partial Reenters js_register_prototype_method Reenters +js_register_readable_handle_predicate Leaf js_register_set_text_handler Reenters js_register_show_toast_handler Reenters js_register_stdin_flow_ops Leaf @@ -2997,6 +3000,7 @@ js_shape_ordinary_inline_slot_for_key Reenters js_shape_register_static_seed Leaf js_shape_run_static_seed Reenters js_shape_seed_plain Reenters +js_shape_seed_plain_constfn Reenters js_shared_array_buffer_new Reenters js_shared_array_buffer_new_value Reenters js_sharp_blur Reenters From 7b38a72865fb186fd875806b6d30c0cb71d77728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 3 Oct 2026 17:28:37 +0000 Subject: [PATCH 5/5] changelog: key the fresh-class follow-ups fragment to PR 11823 --- ...ER-fresh-class-followups.md => 11823-fresh-class-followups.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{PLACEHOLDER-fresh-class-followups.md => 11823-fresh-class-followups.md} (100%) diff --git a/changelog.d/PLACEHOLDER-fresh-class-followups.md b/changelog.d/11823-fresh-class-followups.md similarity index 100% rename from changelog.d/PLACEHOLDER-fresh-class-followups.md rename to changelog.d/11823-fresh-class-followups.md