From f8755820038915aa7ffeccbc66e1cf1fb347236e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 14:44:15 +0200 Subject: [PATCH 01/13] =?UTF-8?q?runtime:=20ObjectMeta.elements=20?= =?UTF-8?q?=E2=80=94=20a=20traced=20elements=20store=20for=20Array-subclas?= =?UTF-8?q?s=20instances=20(foundation,=20gated=20off)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `class X extends Array` instances are ordinary objects whose indexed elements and `length` are shape-carried properties, so every push/pop/[i] is a property-shape transition (≈25% of the wolf-ecs cycle: `Archetype` is its own `packed` array). This adds the backing store the redesign routes those operations to — nothing is routed yet, and the gate (`PERRY_ARRAY_SUBCLASS_ELEMENTS=1`) is off by default. * `ObjectMeta.elements`: `*mut ArrayHeader` bits (0 = none) at the end of the record, zeroed by both meta-ensure paths; a traced child edge exactly like `spill` — enumerated as a second explicit meta slot by the child-slot iterator (`gc/layout.rs`) and visited/rewritten in the ObjectMeta arm of `gc/layout_slot_visit.rs`. * `array/subclass_elements.rs`: the gate, `elements_of`, `set_elements_head` (barriered meta-slot store), `install_elements` (exact-length hole array, owner rooted across the allocations, idempotent). * `js_array_subclass_init` installs the store under the gate instead of the shape-carried `length` property. * Test: the edge survives a forced-evacuation minor GC — rewritten to the moved inner array, values intact, holes still absent. Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ --- crates/perry-runtime/src/array/mod.rs | 3 + .../src/array/subclass_elements.rs | 81 +++++++++++++++++++ .../src/array/subclass_elements_tests.rs | 74 +++++++++++++++++ crates/perry-runtime/src/gc/layout.rs | 26 +++++- .../perry-runtime/src/gc/layout_slot_visit.rs | 6 ++ .../src/node_stream_constructors/builders.rs | 15 ++++ .../src/object/meta_accessors.rs | 2 + crates/perry-runtime/src/object/mod.rs | 9 +++ 8 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 crates/perry-runtime/src/array/subclass_elements.rs create mode 100644 crates/perry-runtime/src/array/subclass_elements_tests.rs diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index 2dcc14c09c..730fdb71eb 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -29,6 +29,7 @@ mod sort; mod species; mod splice_slice; mod subclass; +pub(crate) mod subclass_elements; #[cfg(test)] mod collection_tag_tests; @@ -41,6 +42,8 @@ mod spread_dense_tests; #[cfg(test)] mod strict_store_tests; #[cfg(test)] +mod subclass_elements_tests; +#[cfg(test)] mod subclass_tests; #[cfg(test)] mod tests; diff --git a/crates/perry-runtime/src/array/subclass_elements.rs b/crates/perry-runtime/src/array/subclass_elements.rs new file mode 100644 index 0000000000..35794cd958 --- /dev/null +++ b/crates/perry-runtime/src/array/subclass_elements.rs @@ -0,0 +1,81 @@ +//! Elements backing store for `class X extends Array` instances. +//! +//! An Array-subclass instance is an ordinary `GC_TYPE_OBJECT` (`super::subclass`); +//! today its indexed elements and `length` are shape-carried properties, so every +//! `push`/`pop`/`obj[i] = v` is a property-shape transition. Under +//! [`array_subclass_elements_enabled`] the instance instead owns a real +//! `GC_TYPE_ARRAY` in `ObjectMeta.elements` (a traced child edge exactly like +//! `spill`) holding its indexed elements and `length`, and the property entry +//! points route canonical array-index keys and `length` to it. +//! +//! This module owns the edge: the gate, the accessor, installation at +//! construction, and the barriered head write-back after a re-allocating append. +use crate::array::ArrayHeader; +use crate::object::ObjectHeader; + +/// `PERRY_ARRAY_SUBCLASS_ELEMENTS=1|on|true` — off while the property entry +/// points are being routed; the default flips once the semantics suite is green. +#[inline] +pub(crate) fn array_subclass_elements_enabled() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| { + matches!( + std::env::var("PERRY_ARRAY_SUBCLASS_ELEMENTS").as_deref(), + Ok("1") | Ok("on") | Ok("true") + ) + }) +} + +/// The elements store of a live `GC_TYPE_OBJECT`, or null when it has none +/// (no meta record, or not an elements-backed Array subclass instance). +/// +/// # Safety +/// `obj` must be a live `GC_TYPE_OBJECT` user pointer. +#[inline] +pub(crate) unsafe fn elements_of(obj: *const ObjectHeader) -> *mut ArrayHeader { + let meta = (*obj).meta; + if meta.is_null() { + return std::ptr::null_mut(); + } + (*meta).elements as *mut ArrayHeader +} + +/// Store `elements` as the instance's backing store (a barriered meta-record +/// slot store; `elements` may be null to detach, e.g. on deopt). +/// +/// # Safety +/// `obj` must be a live `GC_TYPE_OBJECT` with a meta record. +#[inline] +pub(crate) unsafe fn set_elements_head(obj: *mut ObjectHeader, elements: *mut ArrayHeader) { + let meta = (*obj).meta; + debug_assert!(!meta.is_null()); + // GC_STORE_AUDIT(BARRIERED): meta-record child edge, stored exactly as + // `reserve_object_spill` stores `spill`. + (*meta).elements = elements as u64; + crate::gc::runtime_write_barrier_slot( + meta as usize, + &(*meta).elements as *const _ as usize, + elements as u64, + ); +} + +/// Install a fresh elements store of `length` holes on `obj` (the `super(n)` +/// shape: `length = n`, every index absent). Idempotent: an instance that +/// already has a store keeps it. +/// +/// # Safety +/// `obj` must be a live `GC_TYPE_OBJECT` user pointer. +pub(crate) unsafe fn install_elements(obj: *mut ObjectHeader, length: u32) { + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_handle = scope.root_raw_mut_ptr(obj); + let (_, obj) = + obj_handle.across_mut::(|| crate::object::object_meta_ensure(obj)); + if !elements_of(obj).is_null() { + return; + } + let (elements, obj) = obj_handle + .across_mut::(|| crate::array::js_array_alloc_with_length_exact(length)); + if elements_of(obj).is_null() { + set_elements_head(obj, elements); + } +} diff --git a/crates/perry-runtime/src/array/subclass_elements_tests.rs b/crates/perry-runtime/src/array/subclass_elements_tests.rs new file mode 100644 index 0000000000..2fa74bdac6 --- /dev/null +++ b/crates/perry-runtime/src/array/subclass_elements_tests.rs @@ -0,0 +1,74 @@ +//! The `ObjectMeta.elements` edge of an Array-subclass instance is a traced +//! child exactly like `spill`: it must survive owner and meta evacuation, be +//! rewritten to the moved inner array, and keep the inner array alive. +use super::subclass_elements::{elements_of, install_elements, set_elements_head}; +use crate::object::{js_object_alloc, ObjectHeader}; + +const CLASS_ID_ARRAY: u32 = 0xFFFF_0024; + +fn live_obj(receiver: f64) -> *mut ObjectHeader { + (receiver.to_bits() & 0x0000_FFFF_FFFF_FFFF) as *mut ObjectHeader +} + +#[test] +fn the_elements_edge_survives_moving_gc_and_keeps_the_inner_array_alive() { + let _copying_nursery = crate::gc::CopyingNurseryTestGuard::new(0); + let _triggers = crate::gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _force_evacuation = crate::gc::knob_overrides::ForcedEvacuationTestGuard::on(); + crate::gc::register_runtime_handle_root_scanner_for_tests(); + crate::gc::gc_register_mutable_root_scanner(crate::object::shapes::scan_shape_table_rekey_mut); + + let class_id = 0x0074_8695; + crate::object::js_register_class_parent(class_id, CLASS_ID_ARRAY); + let obj = js_object_alloc(class_id, 2); + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver_h = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(obj as i64)); + + // `super(3)`: three holes, `length` 3, no shape-carried `length`. + unsafe { install_elements(live_obj(receiver_h.get_nanbox_f64()), 3) }; + let before_elements = unsafe { elements_of(live_obj(receiver_h.get_nanbox_f64())) }; + assert!(!before_elements.is_null()); + assert_eq!(unsafe { (*before_elements).length }, 3); + // Idempotent: a second install keeps the store. + unsafe { install_elements(live_obj(receiver_h.get_nanbox_f64()), 9) }; + assert_eq!( + unsafe { elements_of(live_obj(receiver_h.get_nanbox_f64())) }, + before_elements + ); + + // An append past the exact capacity re-allocates the inner array; the + // head is written back through the barriered meta slot. + let grown = crate::array::js_array_push_f64(before_elements, 44.0); + unsafe { set_elements_head(live_obj(receiver_h.get_nanbox_f64()), grown) }; + crate::array::js_array_set_f64(grown, 0, 11.0); + assert_eq!(unsafe { (*grown).length }, 4); + + let before_cycles = crate::gc::copying_minor_cycles(); + let _ = crate::gc::gc_collect_minor(); + assert!(crate::gc::copying_minor_cycles() > before_cycles); + + let live = live_obj(receiver_h.get_nanbox_f64()); + let elements = unsafe { elements_of(live) }; + assert!( + !elements.is_null(), + "the edge must be rewritten, not dropped" + ); + assert_ne!( + elements, grown, + "forced evacuation must have moved the inner array" + ); + let header = unsafe { crate::value::addr_class::try_read_gc_header(elements as usize) } + .expect("the moved inner array is a live heap object"); + assert_eq!(header.obj_type, crate::gc::GC_TYPE_ARRAY); + assert_eq!(header.gc_flags & crate::gc::GC_FLAG_FORWARDED, 0); + assert_eq!(unsafe { (*elements).length }, 4); + assert_eq!(crate::array::js_array_get_f64(elements, 0), 11.0); + assert_eq!(crate::array::js_array_get_f64(elements, 3), 44.0); + // The untouched indices are still absent (a hole, or `undefined` once the + // read resolves it through the prototype chain), never a stale value. + let hole = crate::array::js_array_get_f64(elements, 1).to_bits(); + assert!( + hole == crate::value::TAG_HOLE || hole == crate::value::TAG_UNDEFINED, + "index 1 must still be absent: {hole:#x}" + ); +} diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index fe69b71707..12b8ae396c 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -1437,6 +1437,9 @@ pub(crate) struct HeapChildSlotIterator { /// #6812: second prefix — the object's `meta` header edge. Kept /// separate from `prefix_slot` so payload indices stay mask-aligned. pub(super) meta_slot: Option<*mut u64>, + /// A second explicit meta edge — the Array-subclass `elements` store at + /// the end of the `ObjectMeta` record — read exactly like `meta_slot`. + pub(super) meta_slot2: Option<*mut u64>, pub(super) payload: HeapSlotRange, pub(super) selection: HeapPayloadSlotSelection, /// #8122: the receiver's `ShapeDescriptor`, resolved ONCE by @@ -1452,6 +1455,7 @@ impl HeapChildSlotIterator { Self { prefix_slot: None, meta_slot: None, + meta_slot2: None, payload: HeapSlotRange::new(std::ptr::null_mut(), 0), selection: HeapPayloadSlotSelection::Empty, object_shape: None, @@ -1467,6 +1471,7 @@ impl HeapChildSlotIterator { Self { prefix_slot, meta_slot: None, + meta_slot2: None, payload, selection, object_shape: None, @@ -1487,6 +1492,7 @@ impl HeapChildSlotIterator { Self { prefix_slot, meta_slot: None, + meta_slot2: None, payload, selection, object_shape, @@ -1498,10 +1504,19 @@ impl HeapChildSlotIterator { self } + pub(super) fn with_meta_slot2(mut self, slot: Option<*mut u64>) -> Self { + self.meta_slot2 = slot; + self + } + pub(super) fn take_meta_child_slot(&mut self) -> Option<*mut u64> { self.meta_slot.take() } + pub(super) fn take_meta_child_slot2(&mut self) -> Option<*mut u64> { + self.meta_slot2.take() + } + pub(super) fn take_prefix_child_slot(&mut self) -> Option<*mut u64> { self.prefix_slot.take() } @@ -1549,6 +1564,9 @@ impl Iterator for HeapChildSlotIterator { if let Some(slot) = self.meta_slot.take() { return Some(HeapChildSlot::Child(slot, HeapChildSlotReadKind::Prefix)); } + if let Some(slot) = self.meta_slot2.take() { + return Some(HeapChildSlot::Child(slot, HeapChildSlotReadKind::Prefix)); + } match &mut self.selection { HeapPayloadSlotSelection::Empty => None, HeapPayloadSlotSelection::PointerFree { @@ -1746,7 +1764,13 @@ pub(super) unsafe fn gc_child_slots(header: *mut GcHeader) -> HeapChildSlotItera let proto_slot = Some(&mut (*meta).prototype as *mut u64); let brand_slot = Some(&mut (*meta).private_evaluation_brand as *mut u64); let range = HeapSlotRange::new(&mut (*meta).spill as *mut u64, 1); - HeapChildSlotIterator::new(header, proto_slot, range).with_meta_slot(brand_slot) + // The Array-subclass elements store is a raw-pointer child edge + // (0 = none) exactly like `spill`; it sits at the end of the + // record, so it is enumerated as a second explicit meta edge. + let elements_slot = Some(&mut (*meta).elements as *mut u64); + HeapChildSlotIterator::new(header, proto_slot, range) + .with_meta_slot(brand_slot) + .with_meta_slot2(elements_slot) } GcLayoutSlotKind::ClosureCaptures => { let closure = user_ptr as *mut crate::closure::ClosureHeader; diff --git a/crates/perry-runtime/src/gc/layout_slot_visit.rs b/crates/perry-runtime/src/gc/layout_slot_visit.rs index c2a7f9c05a..abe78b396d 100644 --- a/crates/perry-runtime/src/gc/layout_slot_visit.rs +++ b/crates/perry-runtime/src/gc/layout_slot_visit.rs @@ -75,6 +75,9 @@ pub(super) unsafe fn visit_gc_layout_slot_descriptors( if let Some(slot) = child_slots.take_meta_child_slot() { visit(fixed_slot(slot).with_layout(HeapChildSlotReadKind::Prefix)); } + if let Some(slot) = child_slots.take_meta_child_slot2() { + visit(fixed_slot(slot).with_layout(HeapChildSlotReadKind::Prefix)); + } match child_slots.payload_scan() { HeapPayloadSlotScan::Empty => {} @@ -326,6 +329,9 @@ pub(super) unsafe fn visit_gc_rewrite_slot_descriptors( // so an unvisited edge here collects a live object's own // properties — the same shape as the spill hazard above (#6812). visit(fixed_slot(&mut (*meta).expando as *mut u64)); + // The Array-subclass elements store (0 = none): a raw-pointer child + // edge traced and rewritten exactly like `spill`. + visit(fixed_slot(&mut (*meta).elements as *mut u64)); // A fresh class object stored as an instance's private evaluation // brand is a NaN-boxed child edge and moves with the meta record. visit(fixed_slot( diff --git a/crates/perry-runtime/src/node_stream_constructors/builders.rs b/crates/perry-runtime/src/node_stream_constructors/builders.rs index aee7a3b187..c7bd99190a 100644 --- a/crates/perry-runtime/src/node_stream_constructors/builders.rs +++ b/crates/perry-runtime/src/node_stream_constructors/builders.rs @@ -194,6 +194,21 @@ pub extern "C" fn js_array_subclass_init(this: f64, n: f64) -> f64 { n.floor().min(MAX_SAFE_INTEGER) } }; + if crate::array::subclass_elements::array_subclass_elements_enabled() { + // Elements-backed instance: `length` and the indices live in the + // store, never as shape-carried properties. + let scope = crate::gc::RuntimeHandleScope::new(); + let this_root = scope.root_nanbox_f64(this); + unsafe { + crate::array::subclass_elements::install_elements(obj, len.min(u32::MAX as f64) as u32) + }; + let this = this_root.get_nanbox_f64(); + let obj = raw_ptr_from_value(this) as *mut ObjectHeader; + crate::closure::js_register_closure_arity(ns_array_fill as *const u8, 1); + let methods: [(&str, StubFn); 1] = [("fill", super::cast1(ns_array_fill))]; + install_methods_on_existing_object(obj, this, &methods, &[]); + return this; + } let length_key = crate::string::js_string_from_bytes(b"length".as_ptr(), 6); js_object_set_field_by_name(obj, length_key, len); crate::closure::js_register_closure_arity(ns_array_fill as *const u8, 1); diff --git a/crates/perry-runtime/src/object/meta_accessors.rs b/crates/perry-runtime/src/object/meta_accessors.rs index 1f8d1742d7..bef1298e42 100644 --- a/crates/perry-runtime/src/object/meta_accessors.rs +++ b/crates/perry-runtime/src/object/meta_accessors.rs @@ -34,6 +34,7 @@ pub(crate) unsafe fn object_meta_ensure_for_cell(user_ptr: usize) -> Option<*mut (*meta).array_subclass_dense_slots = 0; (*meta).array_subclass_dense_bounds = 0; (*meta).expando = 0; + (*meta).elements = 0; // GC_STORE_AUDIT(BARRIERED): header-slot store followed by an object-slot // barrier, exactly as `object_meta_ensure` does for an `ObjectHeader`. *slot = meta; @@ -75,6 +76,7 @@ pub(crate) unsafe fn object_meta_ensure(obj: *mut ObjectHeader) -> *mut ObjectMe (*meta).array_subclass_dense_slots = 0; (*meta).array_subclass_dense_bounds = 0; (*meta).expando = 0; + (*meta).elements = 0; // GC_STORE_AUDIT(BARRIERED): meta-record edge is a header-slot store // followed by an object-slot barrier, mirroring `set_object_keys_array`. (*obj).meta = meta; diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index c229a94b5f..aff75acb0d 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -1611,6 +1611,15 @@ pub struct ObjectMeta { /// child edge: it moves with its owner, dies with its owner, and needs no /// address bookkeeping at all. pub expando: u64, + /// Elements backing store of a `class X extends Array` instance: a + /// `GC_TYPE_ARRAY` (`*mut ArrayHeader` bits, 0 = none) holding the + /// instance's indexed elements and `length`, exactly as a plain Array + /// does — so `push`/`pop`/`obj[i]` are element operations instead of + /// property-shape transitions (`array/subclass_elements.rs`). A traced + /// child edge exactly like `spill`: lives and moves with this record. + /// Installed by `js_array_subclass_init` under + /// `array_subclass_elements_enabled()`; never present otherwise. + pub elements: u64, } pub(crate) const OBJECT_META_FLAG_PROTO_OVERRIDE: u64 = 1; From ab7447d834ba09f3e70788bc62e3fa916c4ec982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 14:47:32 +0200 Subject: [PATCH 02/13] runtime: route the Array-subclass hot entries to the elements store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `array_subclass_fast_length{,_with_ic,_raw}`, `_index_get{,_raw}`, `_index_set{,_raw}`, `_push_one{,_raw,_u31_raw}` and `_pop{,_raw}` — the entries `js_array_length/get/set/push/pop_f64` and the typed-feedback and polymorphic fallbacks reach for an object receiver — now operate on the inner array when the instance has one: length is the inner length, reads are in-bounds non-hole slots (a hole declines to the same prototype-chain fallback as before), in-bounds writes store into the array, the appending index and `push` append with the owner rooted across the re-allocating push and the new head written back through the barriered meta slot, `pop` is the inner pop. Frozen/sealed/non-extensible receivers and hole-creating writes decline to the generic path exactly as the shape-carried form does. The `_with_ic` length entry publishes no IC words for such a receiver (the words describe inline slots it does not have). Test: 40 appends from an exact-capacity-0 store across a forced-evacuation minor GC, reads/writes/pop through both entry families, and the dense shape machinery never learning the instance. Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ --- crates/perry-runtime/src/array/subclass.rs | 111 ++++++++++++++++++ .../src/array/subclass_elements_tests.rs | 70 +++++++++++ 2 files changed, 181 insertions(+) diff --git a/crates/perry-runtime/src/array/subclass.rs b/crates/perry-runtime/src/array/subclass.rs index e10c46ad33..dfe9fe6470 100644 --- a/crates/perry-runtime/src/array/subclass.rs +++ b/crates/perry-runtime/src/array/subclass.rs @@ -352,6 +352,32 @@ fn validated_object_receiver_for_value(value: f64) -> Option Option<*mut ArrayHeader> { + let elements = unsafe { super::subclass_elements::elements_of(receiver.object) }; + (!elements.is_null()).then_some(elements) +} + +/// In-bounds, non-hole element of the inner array; `None` sends the caller +/// to the same prototype-chain fallback a hole in the shape-carried form does. +#[inline] +fn elements_index_get(elements: *const ArrayHeader, index: u32) -> Option { + unsafe { + if index >= (*elements).length { + return None; + } + let slot = (elements as *const u8) + .add(std::mem::size_of::()) + .cast::() + .add(index as usize); + let bits = *slot; + (bits != crate::value::TAG_HOLE).then_some(f64::from_bits(bits)) + } +} + /// Resolve the cached dense layout after the caller has proved that `obj` is /// a live, non-forwarded ordinary object. Every rejected Array-subclass brand, /// descriptor, hole, or prototype case returns `None`. @@ -855,6 +881,11 @@ fn nonnegative_u32_length(value: JSValue) -> Option { /// semantics; descriptor/prototype-divergent shapes decline above. #[inline] pub(crate) fn array_subclass_fast_length(value: f64) -> Option { + if let Some(elements) = + validated_object_receiver_for_value(value).and_then(|r| elements_for_validated(&r)) + { + return Some(f64::from(unsafe { (*elements).length })); + } let (obj, layout) = dense_layout_for_value(value)?; Some(f64::from_bits(layout_length_value(obj, layout).bits())) } @@ -868,6 +899,13 @@ pub(crate) fn array_subclass_fast_length(value: f64) -> Option { /// into the cache, so moving GC needs neither a root nor a rewrite hook. #[inline] pub(crate) fn array_subclass_fast_length_with_ic(value: f64, cache: *mut u64) -> Option { + if let Some(elements) = + validated_object_receiver_for_value(value).and_then(|r| elements_for_validated(&r)) + { + // No shape layout to publish: the IC words describe inline slots, + // and an elements-backed receiver has none for `length`. + return Some(f64::from(unsafe { (*elements).length })); + } let (obj, layout) = dense_layout_for_value(value)?; let result = f64::from_bits(layout_length_value(obj, layout).bits()); if !cache.is_null() { @@ -893,6 +931,9 @@ pub(crate) fn array_subclass_fast_length_with_ic(value: f64, cache: *mut u64) -> pub(crate) fn array_subclass_fast_length_raw(arr: *const ArrayHeader) -> Option { let raw = (arr as u64 & crate::value::POINTER_MASK) as usize; let receiver = validated_object_receiver(raw)?; + if let Some(elements) = elements_for_validated(&receiver) { + return Some(f64::from(unsafe { (*elements).length })); + } let layout = dense_layout_for_validated_object(receiver.object)?; Some(f64::from_bits( layout_length_value(receiver.object, layout).bits(), @@ -904,6 +945,11 @@ pub(crate) fn array_subclass_fast_length_raw(arr: *const ArrayHeader) -> Option< /// proof when a length-only grow created holes without changing the shape. #[inline] pub(crate) fn array_subclass_fast_index_get(value: f64, index: u32) -> Option { + if let Some(elements) = + validated_object_receiver_for_value(value).and_then(|r| elements_for_validated(&r)) + { + return elements_index_get(elements, index); + } let (obj, layout) = dense_layout_for_value(value)?; dense_index_get_with_layout(obj, layout, index) } @@ -915,6 +961,9 @@ pub(crate) fn array_subclass_fast_index_get_raw( ) -> Option { let raw = (arr as u64 & crate::value::POINTER_MASK) as usize; let receiver = validated_object_receiver(raw)?; + if let Some(elements) = elements_for_validated(&receiver) { + return elements_index_get(elements, index); + } let layout = dense_layout_for_validated_object(receiver.object)?; dense_index_get_with_layout(receiver.object, layout, index) } @@ -1127,6 +1176,59 @@ pub(crate) fn array_subclass_fast_index_set(receiver: f64, index: u32, value: f6 array_subclass_fast_index_set_validated(receiver, index, value) } +/// Elements-backed `receiver[index] = value` for an in-bounds index or the +/// appending index (`== length`); anything else (holes past the end, a +/// frozen/sealed/non-extensible receiver) declines to the generic path. +fn elements_index_set(receiver: &ValidatedObjectReceiver, index: u32, value: f64) -> Option { + let elements = elements_for_validated(receiver)?; + if !mutation_receiver_allows_plain_tail(receiver.object_flags) { + return Some(false); + } + let length = unsafe { (*elements).length }; + if index < length { + crate::array::js_array_set_f64(elements, index, value); + return Some(true); + } + if index == length { + return elements_push(receiver, value).map(|_| true); + } + Some(false) +} + +/// Elements-backed append: the owner is rooted across the (possibly +/// re-allocating) push and the new head is written back through the +/// barriered meta slot. Returns the new length. +fn elements_push(receiver: &ValidatedObjectReceiver, value: f64) -> Option { + let elements = elements_for_validated(receiver)?; + if !mutation_receiver_allows_plain_tail(receiver.object_flags) { + return None; + } + let obj = receiver.object as *mut ObjectHeader; + unsafe { + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_handle = scope.root_raw_mut_ptr(obj); + let value_root = scope.root_nanbox_f64(value); + let (grown, obj) = obj_handle.across_mut::(|| { + crate::array::js_array_push_f64(elements, value_root.get_nanbox_f64()) + }); + let current = super::subclass_elements::elements_of(obj); + if grown != current { + super::subclass_elements::set_elements_head(obj, grown); + } + Some(f64::from((*grown).length)) + } +} + +/// Elements-backed `pop`: the inner array's own pop (no allocation, so no +/// rooting), declining like `elements_push` on a non-plain receiver. +fn elements_pop(receiver: &ValidatedObjectReceiver) -> Option { + let elements = elements_for_validated(receiver)?; + if !mutation_receiver_allows_plain_tail(receiver.object_flags) { + return None; + } + Some(crate::array::js_array_pop_f64(elements)) +} + #[inline] pub(crate) fn array_subclass_fast_index_set_raw( arr: *const ArrayHeader, @@ -1146,6 +1248,9 @@ fn array_subclass_fast_index_set_validated( index: u32, value: f64, ) -> bool { + if let Some(done) = elements_index_set(&receiver, index, value) { + return done; + } let obj = receiver.object; let Some(layout) = dense_layout_for_validated_object(obj) else { return false; @@ -1226,6 +1331,9 @@ fn array_subclass_fast_push_one_validated( value: f64, proven_u31: Option, ) -> Option { + if elements_for_validated(&receiver).is_some() { + return elements_push(&receiver, value); + } let obj = receiver.object; let layout = dense_layout_for_validated_object(obj)?; let length = nonnegative_u32_length(layout_length_value(obj, layout))?; @@ -1351,6 +1459,9 @@ pub(crate) fn array_subclass_fast_pop_raw(arr: *const ArrayHeader) -> Option Option { + if elements_for_validated(&receiver).is_some() { + return elements_pop(&receiver); + } let obj = receiver.object; let layout = dense_layout_for_validated_object(obj)?; let length = nonnegative_u32_length(layout_length_value(obj, layout))?; diff --git a/crates/perry-runtime/src/array/subclass_elements_tests.rs b/crates/perry-runtime/src/array/subclass_elements_tests.rs index 2fa74bdac6..5069d03bbb 100644 --- a/crates/perry-runtime/src/array/subclass_elements_tests.rs +++ b/crates/perry-runtime/src/array/subclass_elements_tests.rs @@ -72,3 +72,73 @@ fn the_elements_edge_survives_moving_gc_and_keeps_the_inner_array_alive() { "index 1 must still be absent: {hole:#x}" ); } + +/// The hot runtime entries route an elements-backed instance to its inner +/// array: `length`, `[i]` get/set, append (including the re-allocating one, +/// with the owner rooted across it) and pop — through both the value-taking +/// `array_subclass_fast_*` entries and the raw `js_array_*` entries the +/// codegen fallbacks call, and across a forced-evacuation minor GC. +#[test] +fn hot_entries_route_to_the_elements_store() { + use super::subclass::{ + array_subclass_fast_index_get, array_subclass_fast_index_set, array_subclass_fast_length, + array_subclass_fast_pop, array_subclass_fast_push_one, + }; + let _copying_nursery = crate::gc::CopyingNurseryTestGuard::new(0); + let _triggers = crate::gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _force_evacuation = crate::gc::knob_overrides::ForcedEvacuationTestGuard::on(); + crate::gc::register_runtime_handle_root_scanner_for_tests(); + crate::gc::gc_register_mutable_root_scanner(crate::object::shapes::scan_shape_table_rekey_mut); + + let class_id = 0x0074_8696; + crate::object::js_register_class_parent(class_id, CLASS_ID_ARRAY); + let obj = js_object_alloc(class_id, 2); + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver_h = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(obj as i64)); + unsafe { install_elements(live_obj(receiver_h.get_nanbox_f64()), 0) }; + let recv = || receiver_h.get_nanbox_f64(); + let as_arr = || (recv().to_bits() & 0x0000_FFFF_FFFF_FFFF) as *mut crate::array::ArrayHeader; + + assert_eq!(array_subclass_fast_length(recv()), Some(0.0)); + assert_eq!(array_subclass_fast_index_get(recv(), 0), None); + // 40 appends from an exact-capacity-0 store: several re-allocations. + for i in 0..40u32 { + assert_eq!( + array_subclass_fast_push_one(recv(), f64::from(i)), + Some(f64::from(i + 1)) + ); + if i == 17 { + let _ = crate::gc::gc_collect_minor(); + } + } + assert_eq!(array_subclass_fast_length(recv()), Some(40.0)); + for i in 0..40u32 { + assert_eq!(array_subclass_fast_index_get(recv(), i), Some(f64::from(i))); + } + assert_eq!(array_subclass_fast_index_get(recv(), 40), None); + // In-bounds write, appending write, hole-creating write (declined). + assert!(array_subclass_fast_index_set(recv(), 3, 300.0)); + assert_eq!(array_subclass_fast_index_get(recv(), 3), Some(300.0)); + assert!(array_subclass_fast_index_set(recv(), 40, 400.0)); + assert_eq!(array_subclass_fast_length(recv()), Some(41.0)); + assert!(!array_subclass_fast_index_set(recv(), 50, 500.0)); + assert_eq!(array_subclass_fast_length(recv()), Some(41.0)); + // Pop through the value entry and through the raw `js_array_*` entries + // the codegen fallbacks call with the object address as an ArrayHeader. + assert_eq!(array_subclass_fast_pop(recv()), Some(400.0)); + assert_eq!(crate::array::js_array_pop_f64(as_arr()), 39.0); + assert_eq!(crate::array::js_array_length(as_arr()), 39); + let _ = crate::array::js_array_push_f64(as_arr(), 77.0); + assert_eq!(crate::array::js_array_length(as_arr()), 40); + assert_eq!(crate::array::js_array_get_f64(as_arr(), 39), 77.0); + assert_eq!(array_subclass_fast_index_get(recv(), 39), Some(77.0)); + let _ = crate::gc::gc_collect_minor(); + assert_eq!(array_subclass_fast_length(recv()), Some(40.0)); + assert_eq!(array_subclass_fast_index_get(recv(), 3), Some(300.0)); + assert_eq!(crate::array::js_array_get_f64(as_arr(), 39), 77.0); + // The shape-carried machinery never learned anything for this instance. + assert_eq!( + unsafe { (*(*live_obj(recv())).meta).array_subclass_dense_key }, + 0 + ); +} From 7b5d9c95ae65df326518939656cdcfbe505ccfd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 15:00:33 +0200 Subject: [PATCH 03/13] =?UTF-8?q?runtime:=20the=20elements=20property=20fu?= =?UTF-8?q?nnel=20=E2=80=94=20indices=20and=20length=20of=20an=20elements-?= =?UTF-8?q?backed=20Array=20subclass=20through=20every=20object=20entry=20?= =?UTF-8?q?point?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the store in place, the ordinary-object entry points now ask `array/subclass_elements.rs` first for a canonical array-index key or `length` on an elements-backed instance: * get by name (`js_object_get_field_by_name`, the object tail, the IC miss): `length` and in-bounds non-hole elements; a hole falls through to the ordinary lookup, which reaches the prototype chain since the shape carries no index keys; * set by name (`js_object_set_field_by_name`, the object tail): in-bounds store, append, hole-creating extension (`js_array_set_f64_extend`), and `length` writes with Array semantics (truncate; extend with holes; RangeError otherwise) — owner rooted across the re-allocating cases, head written back through the barriered meta slot; no index key ever becomes a shape property; * `hasOwnProperty` / `in` (own hit answers, an absent index continues to the prototype walk), `delete` (index → hole, `length` non-configurable), `getOwnPropertyDescriptor` (data descriptors; `length` non-enumerable, non-configurable), `Object.keys` (present indices ascending, then the shape's keys) and `getOwnPropertyNames` (indices, `length`, keys) as thin wrappers over the shape-only walkers; * `JSON.stringify` serializes the instance as an array (IsArray is true); iteration/spread/concat use the live inner array instead of a snapshot; * `array_object_set_length` and the Array-exotic length maintenance route to the store; * exotic operations leave the representation for good — `defineProperty` on an index or `length`, `defineProperties`, freeze/seal/preventExtensions, `setPrototypeOf` call `deopt_to_shape`, which materialises every present element and `length` as shape-carried properties and detaches the store, so the long tail keeps running on the existing machinery. Also fixes the array-path dispatch in `getOwnPropertyNames` (#8953): it was gated on `Array.isArray`, which is true for a subclass instance whose cell is an `ObjectHeader`, and dereferenced a null cleaned array pointer; the gate is now "the cell is a GC_TYPE_ARRAY". Tests: the funnel end to end (get/set/append/extend/truncate, hasOwn/in, delete, key order, descriptors, no index key in the shape) and freeze deopting to the shape-carried form with identical reads afterwards. Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ --- crates/perry-runtime/src/array/subclass.rs | 17 + .../src/array/subclass_elements.rs | 352 ++++++++++++++++++ .../src/array/subclass_elements_tests.rs | 182 +++++++++ crates/perry-runtime/src/json/stringify.rs | 8 + .../perry-runtime/src/object/delete_rest.rs | 5 + .../perry-runtime/src/object/descriptors.rs | 50 ++- .../src/object/field_get_set/enumeration.rs | 21 ++ .../object/field_get_set/get_field_by_name.rs | 12 + .../field_get_set/get_field_by_name_tail.rs | 12 + .../src/object/field_get_set/has_property.rs | 9 + .../src/object/field_get_set/ic_miss.rs | 16 + .../src/object/field_set_by_name.rs | 17 + .../src/object/field_set_by_name/tail.rs | 17 + crates/perry-runtime/src/object/mod.rs | 2 +- .../object/object_ops/define_properties.rs | 2 + .../src/object/object_ops/define_property.rs | 8 + .../src/object/object_ops/has_own.rs | 11 + .../src/object/object_ops_frozen.rs | 3 + 18 files changed, 741 insertions(+), 3 deletions(-) diff --git a/crates/perry-runtime/src/array/subclass.rs b/crates/perry-runtime/src/array/subclass.rs index dfe9fe6470..1a8f67a51b 100644 --- a/crates/perry-runtime/src/array/subclass.rs +++ b/crates/perry-runtime/src/array/subclass.rs @@ -1712,6 +1712,11 @@ pub fn is_array_subclass_instance(object: f64) -> bool { /// therefore yields `undefined` rather than a preserved hole — an accepted /// limitation for this rare case. pub fn array_subclass_dense_snapshot(recv: f64) -> f64 { + // An elements-backed instance iterates its live inner array, exactly as + // a plain Array does (no snapshot: a live length, live holes). + if let Some((_, elements)) = crate::array::subclass_elements::backed_value(recv) { + return crate::value::js_nanbox_pointer(elements as i64); + } let len = al_length(recv).max(0); // ArrayCreate throws a RangeError for len ≥ 2^32 (matching `js_arraylike_map`) // — and, critically, this guard prevents the `as u32` truncation below from @@ -1925,6 +1930,11 @@ pub(crate) fn array_object_index_set(recv: f64, index: u32, value: f64) { /// on a bounded parent walk. `key` is a property-key VALUE; a non-canonical /// array index (`"length"`, `"foo"`, `"01"`, a symbol) is a no-op. pub(crate) fn note_array_subclass_index_write(recv: f64, key: f64) { + // An elements-backed instance's `length` is the inner array's: the index + // store already maintained it, and no numeric proof lives on the shape. + if crate::array::subclass_elements::backed_value(recv).is_some() { + return; + } // Stringifying a numeric key can allocate and evacuate the object. Keep // both inputs live, then re-read the receiver before retiring its proof. let scope = crate::gc::RuntimeHandleScope::new(); @@ -1959,6 +1969,9 @@ pub(crate) fn note_array_subclass_index_write(recv: f64, key: f64) { /// generic OBJECT index-store funnels can apply it without re-entering the /// store. pub(crate) fn maintain_array_exotic_length(recv: f64, index: u32) { + if crate::array::subclass_elements::backed_value(recv).is_some() { + return; + } let current = al_length(recv); if (index as i64) < current { return; @@ -1981,6 +1994,10 @@ pub(crate) fn maintain_array_exotic_length(recv: f64, index: u32) { #[cold] #[inline(never)] pub(crate) fn array_object_set_length(recv: f64, new_length: f64) { + if let Some((obj, elements)) = crate::array::subclass_elements::backed_value(recv) { + unsafe { crate::array::subclass_elements::set_length(obj, elements, new_length) }; + return; + } if !new_length.is_finite() || new_length < 0.0 || new_length.trunc() != new_length { crate::array::array_length_range_error(); } diff --git a/crates/perry-runtime/src/array/subclass_elements.rs b/crates/perry-runtime/src/array/subclass_elements.rs index 35794cd958..d091fc7180 100644 --- a/crates/perry-runtime/src/array/subclass_elements.rs +++ b/crates/perry-runtime/src/array/subclass_elements.rs @@ -79,3 +79,355 @@ pub(crate) unsafe fn install_elements(obj: *mut ObjectHeader, length: u32) { set_elements_head(obj, elements); } } + +// --------------------------------------------------------------------------- +// The property funnel: every ordinary-object entry point that can see an +// elements-backed instance's indexed properties or `length` asks here first. +// --------------------------------------------------------------------------- + +/// A property key an elements-backed instance answers itself. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ElementsKey { + Index(u32), + Length, +} + +/// The live `(object, elements)` pair behind a raw or NaN-box-tagged object +/// address, or `None` for anything that is not an elements-backed instance. +/// +/// # Safety +/// `addr` must be a raw user pointer or a POINTER-tagged NaN-box; it is +/// classified before any dereference. +pub(crate) unsafe fn backed(addr: usize) -> Option<(*mut ObjectHeader, *mut ArrayHeader)> { + let bits = addr as u64; + let raw = if (bits >> 48) == 0x7FFD { + (bits & crate::value::POINTER_MASK) as usize + } else { + addr + }; + let header = crate::value::addr_class::try_read_gc_header(raw)?; + if header.obj_type != crate::gc::GC_TYPE_OBJECT + || header.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 + { + return None; + } + let obj = raw as *mut ObjectHeader; + let elements = elements_of(obj); + (!elements.is_null()).then_some((obj, elements)) +} + +/// [`backed`] for a NaN-boxed value. +pub(crate) fn backed_value(value: f64) -> Option<(*mut ObjectHeader, *mut ArrayHeader)> { + let js = crate::JSValue::from_bits(value.to_bits()); + if !js.is_pointer() { + return None; + } + unsafe { backed(value.to_bits() as usize) } +} + +pub(crate) fn key_of_str(name: &str) -> Option { + if name == "length" { + return Some(ElementsKey::Length); + } + crate::object::canonical_array_index(name).map(ElementsKey::Index) +} + +/// # Safety +/// `key` must be null or a live string header. +pub(crate) unsafe fn key_of_header(key: *const crate::StringHeader) -> Option { + if key.is_null() { + return None; + } + crate::object::has_own_helpers::str_from_string_header(key).and_then(key_of_str) +} + +/// A NaN-boxed property key: a canonical-index number, or a string naming an +/// index or `length`. Symbols and everything else are never elements keys. +pub(crate) fn key_of_value(key: f64) -> Option { + let js = crate::JSValue::from_bits(key.to_bits()); + if js.is_number() { + let n = js.as_number(); + if n.is_finite() && n >= 0.0 && n < 4_294_967_295.0 && n.fract() == 0.0 { + return Some(ElementsKey::Index(n as u32)); + } + return None; + } + if js.is_any_string() { + let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + return unsafe { crate::string::js_string_key_bytes(js, &mut sso) } + .and_then(|b| std::str::from_utf8(b).ok()) + .and_then(key_of_str); + } + None +} + +#[inline] +unsafe fn slot_bits(elements: *const ArrayHeader, index: u32) -> u64 { + *(elements as *const u8) + .add(std::mem::size_of::()) + .cast::() + .add(index as usize) +} + +/// Own value for `key`: `length`, or an in-bounds non-hole element. `None` +/// means "not an own property" — the caller continues with its ordinary +/// lookup (the shape carries no such key, so that reaches the prototype +/// chain exactly as a hole on a plain Array does). +/// +/// # Safety +/// `elements` must be the live store of a validated instance. +pub(crate) unsafe fn get_by_key(elements: *const ArrayHeader, key: ElementsKey) -> Option { + match key { + ElementsKey::Length => Some(f64::from((*elements).length)), + ElementsKey::Index(index) => { + if index >= (*elements).length { + return None; + } + let bits = slot_bits(elements, index); + (bits != crate::value::TAG_HOLE).then_some(f64::from_bits(bits)) + } + } +} + +/// `[[Set]]` of an elements key: an in-bounds store, an append, a hole-creating +/// extension, or a `length` write (Array semantics: truncate or extend with +/// holes; a non-index `length` is a RangeError). The owner is rooted across +/// the re-allocating cases and the new head is written back. +/// +/// # Safety +/// `obj` must be a validated elements-backed instance and `elements` its store. +pub(crate) unsafe fn set_by_key( + obj: *mut ObjectHeader, + elements: *mut ArrayHeader, + key: ElementsKey, + value: f64, +) { + match key { + ElementsKey::Length => set_length(obj, elements, value), + ElementsKey::Index(index) => { + let length = (*elements).length; + if index < length { + crate::array::js_array_set_f64(elements, index, value); + return; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_handle = scope.root_raw_mut_ptr(obj); + let value_root = scope.root_nanbox_f64(value); + let (head, obj) = obj_handle.across_mut::(|| { + if index == length { + crate::array::js_array_push_f64(elements, value_root.get_nanbox_f64()) + } else { + crate::array::js_array_set_f64_extend( + elements, + index, + value_root.get_nanbox_f64(), + ) + } + }); + if !head.is_null() && head != elements_of(obj) { + set_elements_head(obj, head); + } + } + } +} + +/// `length = n` on an elements-backed instance. +/// +/// # Safety +/// As [`set_by_key`]. +pub(crate) unsafe fn set_length( + obj: *mut ObjectHeader, + elements: *mut ArrayHeader, + new_length: f64, +) { + if !new_length.is_finite() + || new_length < 0.0 + || new_length.fract() != 0.0 + || new_length >= 4_294_967_296.0 + { + crate::array::array_length_range_error(); + } + let target = new_length as u32; + let current = (*elements).length; + if target <= current { + // Truncation never allocates. + crate::array::js_array_set_length_strict(elements, new_length); + return; + } + // Extension: grow through the index store (which returns the head), then + // punch the written slot back out into a hole. + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_handle = scope.root_raw_mut_ptr(obj); + let (head, obj) = obj_handle.across_mut::(|| { + crate::array::js_array_set_f64_extend( + elements, + target - 1, + f64::from_bits(crate::value::TAG_UNDEFINED), + ) + }); + let head = if head.is_null() { elements } else { head }; + if head != elements_of(obj) { + set_elements_head(obj, head); + } + crate::array::js_array_delete(head, target - 1); +} + +/// # Safety +/// `elements` must be a live store. +pub(crate) unsafe fn has_own_key(elements: *const ArrayHeader, key: ElementsKey) -> bool { + match key { + ElementsKey::Length => true, + ElementsKey::Index(index) => { + index < (*elements).length && slot_bits(elements, index) != crate::value::TAG_HOLE + } + } +} + +/// `delete obj[key]`: an index becomes a hole (1); `length` is +/// non-configurable (0). +/// +/// # Safety +/// `elements` must be a live store. +pub(crate) unsafe fn delete_key(elements: *mut ArrayHeader, key: ElementsKey) -> i32 { + match key { + ElementsKey::Length => 0, + ElementsKey::Index(index) => { + if index < (*elements).length { + crate::array::js_array_delete(elements, index) + } else { + 1 + } + } + } +} + +/// The present (non-hole) indices, ascending. +/// +/// # Safety +/// `elements` must be a live store. +pub(crate) unsafe fn own_index_keys(elements: *const ArrayHeader) -> Vec { + let length = (*elements).length; + let mut out = Vec::new(); + for index in 0..length { + if slot_bits(elements, index) != crate::value::TAG_HOLE { + out.push(index); + } + } + out +} + +/// A fresh keys array: the present indices as strings (ascending), then +/// `"length"` when `with_length` (getOwnPropertyNames), then every key of +/// `shape_keys` in order. `shape_keys` and the result are rooted across the +/// string allocations. +/// +/// # Safety +/// `elements` must be a live store; `shape_keys` a live array (or null). +pub(crate) unsafe fn prepend_index_keys( + elements: *const ArrayHeader, + shape_keys: *mut ArrayHeader, + with_length: bool, +) -> *mut ArrayHeader { + let indices = own_index_keys(elements); + let scope = crate::gc::RuntimeHandleScope::new(); + let shape_h = scope.root_raw_mut_ptr(shape_keys); + let out_h = scope.root_raw_mut_ptr(crate::array::js_array_alloc(0)); + let push_key = |bytes: &[u8]| { + let (s, _) = out_h.across_mut::(|| { + crate::string::js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32) + }); + let (grown, _) = out_h.across_mut::(|| { + let out: *mut ArrayHeader = out_h.with_mut_ptr(|p| p); + crate::array::js_array_push_f64(out, crate::value::js_nanbox_string(s as i64)) + }); + out_h.set_raw_mut_ptr(grown); + }; + for index in indices { + push_key(index.to_string().as_bytes()); + } + if with_length { + push_key(b"length"); + } + let shape_len = shape_h.with_mut_ptr(|p: *mut ArrayHeader| { + if p.is_null() { + 0 + } else { + crate::array::js_array_length(p) + } + }); + for i in 0..shape_len { + let key = shape_h.with_mut_ptr(|p: *mut ArrayHeader| crate::array::js_array_get(p, i)); + let (grown, _) = out_h.across_mut::(|| { + let out: *mut ArrayHeader = out_h.with_mut_ptr(|p| p); + crate::array::js_array_push_f64(out, f64::from_bits(key.bits())) + }); + out_h.set_raw_mut_ptr(grown); + } + out_h.with_mut_ptr(|p| p) +} + +/// The own property descriptor of an elements key, or `None` when the key is +/// not an own property (a hole, an index past `length`). +/// +/// # Safety +/// `obj` must be a validated instance and `elements` its live store. +pub(crate) unsafe fn own_property_descriptor( + obj: *const ObjectHeader, + elements: *const ArrayHeader, + key: ElementsKey, +) -> Option { + let value = get_by_key(elements, key)?; + let frozen = crate::value::addr_class::try_read_gc_header(obj as usize) + .is_some_and(|h| h._reserved & crate::gc::OBJ_FLAG_FROZEN != 0); + Some(match key { + ElementsKey::Index(_) => { + crate::object::descriptors::build_data_descriptor(value, !frozen, true, !frozen) + } + ElementsKey::Length => { + crate::object::descriptors::build_data_descriptor(value, !frozen, false, false) + } + }) +} + +/// Leave the elements representation for good: every present element becomes +/// a shape-carried index property (ascending), then `length`, and the store is +/// detached. Everything after this runs on the shape-carried machinery +/// (`super::subclass`, `object/array_tail_transition.rs`) exactly as before. +/// Called by the exotic operations that machinery already models — +/// `defineProperty` on an index or `length`, accessor installs, freeze/seal/ +/// preventExtensions, `setPrototypeOf` — before they do their work. +/// +/// # Safety +/// `obj` must be a validated elements-backed instance. +pub(crate) unsafe fn deopt_to_shape(obj: *mut ObjectHeader) { + let elements = elements_of(obj); + if elements.is_null() { + return; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_h = scope.root_raw_mut_ptr(obj); + let elements_h = scope.root_raw_mut_ptr(elements); + // Detach first: the index stores below must not be routed back here. + set_elements_head(obj, std::ptr::null_mut()); + let length = (*elements).length; + for index in own_index_keys(elements) { + let value = + elements_h.with_mut_ptr(|e: *mut ArrayHeader| f64::from_bits(slot_bits(e, index))); + let name = index.to_string(); + let (key, obj) = obj_h.across_mut::(|| { + crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32) + }); + crate::object::js_object_set_field_by_name(obj, key, value); + } + let (key, obj) = obj_h.across_mut::(|| { + crate::string::js_string_from_bytes(b"length".as_ptr(), 6) + }); + crate::object::set_field_by_name_object_tail(obj, key, f64::from(length)); +} + +/// [`deopt_to_shape`] for a NaN-boxed receiver that may not be elements-backed. +pub(crate) fn deopt_value(value: f64) { + if let Some((obj, _)) = backed_value(value) { + unsafe { deopt_to_shape(obj) }; + } +} diff --git a/crates/perry-runtime/src/array/subclass_elements_tests.rs b/crates/perry-runtime/src/array/subclass_elements_tests.rs index 5069d03bbb..d49ea3dfd9 100644 --- a/crates/perry-runtime/src/array/subclass_elements_tests.rs +++ b/crates/perry-runtime/src/array/subclass_elements_tests.rs @@ -142,3 +142,185 @@ fn hot_entries_route_to_the_elements_store() { 0 ); } + +fn key(name: &str) -> *const crate::StringHeader { + crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32) +} +fn key_value(name: &str) -> f64 { + crate::value::js_nanbox_string(key(name) as i64) +} +fn key_strings(arr: *const crate::array::ArrayHeader) -> Vec { + let n = crate::array::js_array_length(arr); + (0..n) + .map(|i| { + let v = crate::array::js_array_get(arr, i); + let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + unsafe { crate::string::js_string_key_bytes(v, &mut sso) } + .map(|b| String::from_utf8_lossy(b).into_owned()) + .unwrap_or_else(|| "".to_string()) + }) + .collect() +} +fn truthy(v: f64) -> bool { + v.to_bits() == 0x7FFC_0000_0000_0004 +} + +/// The property funnel: through the ordinary object entry points, an +/// elements-backed instance's indices and `length` are own properties backed +/// by the inner array — reads, writes (in-bounds, append, hole-creating +/// extension, `length` truncation/extension), `hasOwnProperty`/`in`, +/// `delete`, key order for `Object.keys`/`getOwnPropertyNames`, and own +/// property descriptors — and no index key ever lands in the shape. +#[test] +fn the_property_funnel_answers_indices_and_length_from_the_store() { + let _triggers = crate::gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + crate::gc::register_runtime_handle_root_scanner_for_tests(); + let class_id = 0x0074_8697; + crate::object::js_register_class_parent(class_id, CLASS_ID_ARRAY); + let obj = js_object_alloc(class_id, 2); + let scope = crate::gc::RuntimeHandleScope::new(); + let recv_h = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(obj as i64)); + unsafe { install_elements(live_obj(recv_h.get_nanbox_f64()), 0) }; + let recv = || recv_h.get_nanbox_f64(); + let obj = || live_obj(recv()); + let get = |name: &str| crate::object::js_object_get_field_by_name(obj(), key(name)); + let set = |name: &str, v: f64| crate::object::js_object_set_field_by_name(obj(), key(name), v); + + // A named field stays a shape property; `length` and indices do not. + set("tag", 7.0); + set("0", 10.0); + set("1", 11.0); + set("2", 12.0); + assert_eq!(get("length").as_number(), 3.0); + assert_eq!(get("1").as_number(), 11.0); + assert_eq!(get("tag").as_number(), 7.0); + assert!(get("5").is_undefined()); + // Hole-creating extension, then `length` truncation and extension. + set("5", 15.0); + assert_eq!(get("length").as_number(), 6.0); + assert!(get("3").is_undefined()); + assert_eq!(get("5").as_number(), 15.0); + set("length", 2.0); + assert_eq!(get("length").as_number(), 2.0); + assert!(get("2").is_undefined()); + set("length", 4.0); + assert_eq!(get("length").as_number(), 4.0); + assert!(get("3").is_undefined()); + set("3", 13.0); + // hasOwn / in. + assert!(truthy(crate::object::js_object_has_own( + recv(), + key_value("0") + ))); + assert!(truthy(crate::object::js_object_has_own( + recv(), + key_value("length") + ))); + assert!(!truthy(crate::object::js_object_has_own( + recv(), + key_value("2") + ))); + assert!(!truthy(crate::object::js_object_has_own( + recv(), + key_value("9") + ))); + assert!(truthy(crate::object::js_object_has_property(recv(), 3.0))); + assert!(!truthy(crate::object::js_object_has_property(recv(), 2.0))); + assert!(truthy(crate::object::js_object_has_property( + recv(), + key_value("tag") + ))); + // delete: an index becomes a hole, `length` is untouched and undeletable. + assert_eq!(crate::object::js_object_delete_dynamic(obj(), 0.0), 1); + assert!(get("0").is_undefined()); + assert_eq!(get("length").as_number(), 4.0); + assert_eq!( + crate::object::js_object_delete_dynamic(obj(), key_value("length")), + 0 + ); + // Key order: present indices ascending, then shape keys; `length` only + // in getOwnPropertyNames, between them. + assert_eq!( + key_strings(crate::object::js_object_keys(obj())), + vec!["1", "3", "tag"] + ); + let names = crate::object::js_object_get_own_property_names(recv()); + assert_eq!( + key_strings(crate::value::js_nanbox_get_pointer(names) as *const crate::array::ArrayHeader), + vec!["1", "3", "length", "tag"] + ); + // Descriptors. + let d = crate::object::js_object_get_own_property_descriptor(recv(), key_value("1")); + let dobj = crate::value::js_nanbox_get_pointer(d) as *const ObjectHeader; + assert_eq!( + crate::object::js_object_get_field_by_name(dobj, key("value")).as_number(), + 11.0 + ); + assert!(truthy(f64::from_bits( + crate::object::js_object_get_field_by_name(dobj, key("enumerable")).bits() + ))); + let d = crate::object::js_object_get_own_property_descriptor(recv(), key_value("length")); + let dobj = crate::value::js_nanbox_get_pointer(d) as *const ObjectHeader; + assert_eq!( + crate::object::js_object_get_field_by_name(dobj, key("value")).as_number(), + 4.0 + ); + assert!(!truthy(f64::from_bits( + crate::object::js_object_get_field_by_name(dobj, key("enumerable")).bits() + ))); + assert!(crate::JSValue::from_bits( + crate::object::js_object_get_own_property_descriptor(recv(), key_value("0")).to_bits() + ) + .is_undefined()); + // The shape never learned an index key. + assert!(!key_strings(crate::object::js_object_keys(obj())) + .iter() + .any(|k| k == "0" || k == "1" && false)); + assert!(!unsafe { elements_of(obj()) }.is_null()); +} + +/// `Object.freeze` leaves the elements representation for good: every present +/// element and `length` become shape-carried properties, the store is +/// detached, and the frozen instance reads back exactly the same. +#[test] +fn freeze_deopts_to_the_shape_carried_form() { + let _triggers = crate::gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + crate::gc::register_runtime_handle_root_scanner_for_tests(); + let class_id = 0x0074_8698; + crate::object::js_register_class_parent(class_id, CLASS_ID_ARRAY); + let obj = js_object_alloc(class_id, 2); + let scope = crate::gc::RuntimeHandleScope::new(); + let recv_h = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(obj as i64)); + unsafe { install_elements(live_obj(recv_h.get_nanbox_f64()), 0) }; + let recv = || recv_h.get_nanbox_f64(); + let obj = || live_obj(recv()); + for i in 0..3u32 { + crate::object::js_object_set_field_by_name(obj(), key(&i.to_string()), f64::from(i * 10)); + } + crate::object::js_object_delete_dynamic(obj(), 1.0); + crate::object::js_object_set_field_by_name(obj(), key("tag"), 7.0); + let _ = crate::object::js_object_freeze(recv()); + assert!( + unsafe { elements_of(obj()) }.is_null(), + "the store is detached on freeze" + ); + let get = |name: &str| crate::object::js_object_get_field_by_name(obj(), key(name)); + assert_eq!(get("length").as_number(), 3.0); + assert_eq!(get("0").as_number(), 0.0); + assert!(get("1").is_undefined()); + assert_eq!(get("2").as_number(), 20.0); + assert_eq!(get("tag").as_number(), 7.0); + assert!(truthy(crate::object::js_object_has_own( + recv(), + key_value("2") + ))); + assert!(!truthy(crate::object::js_object_has_own( + recv(), + key_value("1") + ))); + // Frozen: the shape-carried machinery refuses the append. + assert_eq!( + super::subclass::array_subclass_fast_push_one(recv(), 99.0), + None + ); +} diff --git a/crates/perry-runtime/src/json/stringify.rs b/crates/perry-runtime/src/json/stringify.rs index 6718792ea6..5979836d49 100644 --- a/crates/perry-runtime/src/json/stringify.rs +++ b/crates/perry-runtime/src/json/stringify.rs @@ -551,6 +551,11 @@ pub(crate) unsafe fn stringify_value(value: f64, type_hint: u32, buf: &mut Strin // capacity heuristic (`cap < 10000`) misidentified legitimate // arrays that had grown past 10k as strings, panicking on // `JSON.stringify(arr)` where `arr.length >= 10000` (issue #43). + // An elements-backed Array-subclass instance IS an Array to + // `JSON.stringify` (IsArray is true): serialize its elements. + if let Some((_, elements)) = crate::array::subclass_elements::backed(ptr as usize) { + return stringify_array(elements as *const u8, buf); + } match gc_obj_type(ptr) { crate::gc::GC_TYPE_ARRAY => stringify_array(ptr, buf), // A function has no ordinary object/array/string/error/map/set @@ -814,6 +819,9 @@ pub(crate) unsafe fn stringify_value_depth( buf.push_str("null"); return; } + if let Some((_, elements)) = crate::array::subclass_elements::backed(ptr as usize) { + return stringify_array_depth(elements as *const u8, buf, depth); + } match gc_obj_type(ptr) { crate::gc::GC_TYPE_OBJECT => stringify_object_inner(ptr, buf, depth), crate::gc::GC_TYPE_ARRAY => stringify_array_depth(ptr, buf, depth), diff --git a/crates/perry-runtime/src/object/delete_rest.rs b/crates/perry-runtime/src/object/delete_rest.rs index 037fd19985..00d84accba 100644 --- a/crates/perry-runtime/src/object/delete_rest.rs +++ b/crates/perry-runtime/src/object/delete_rest.rs @@ -514,6 +514,11 @@ pub extern "C" fn js_object_delete_dynamic_value(obj_value: f64, key: f64) -> i3 /// Returns 1 if successful, 0 otherwise #[no_mangle] pub extern "C" fn js_object_delete_dynamic(obj: *mut ObjectHeader, key: f64) -> i32 { + if let Some((_, elements)) = unsafe { crate::array::subclass_elements::backed(obj as usize) } { + if let Some(elements_key) = crate::array::subclass_elements::key_of_value(key) { + return unsafe { crate::array::subclass_elements::delete_key(elements, elements_key) }; + } + } // Proxy receiver (small registered id) — route through the proxy // `deleteProperty` trap before any key coercion that would deref the fake // pointer. Handles symbol keys too (the string path also funnels into diff --git a/crates/perry-runtime/src/object/descriptors.rs b/crates/perry-runtime/src/object/descriptors.rs index a0d0689350..35565161ba 100644 --- a/crates/perry-runtime/src/object/descriptors.rs +++ b/crates/perry-runtime/src/object/descriptors.rs @@ -121,6 +121,16 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu if crate::proxy::js_proxy_is_proxy(obj_value) != 0 { return crate::proxy::js_reflect_get_own_property_descriptor(obj_value, key_value); } + if let Some((obj, elements)) = crate::array::subclass_elements::backed_value(obj_value) { + if let Some(elements_key) = crate::array::subclass_elements::key_of_value(key_value) { + return crate::array::subclass_elements::own_property_descriptor( + obj, + elements, + elements_key, + ) + .unwrap_or(f64::from_bits(crate::value::TAG_UNDEFINED)); + } + } // #6363: a native HANDLE receiver (zlib stream, fetch Headers/Request/ // Response/Blob, crypto hash, …) is a pointer-tagged registry id, not a @@ -1223,6 +1233,42 @@ unsafe fn string_primitive_descriptor(str_value: f64, key_value: f64) -> f64 { /// Takes a NaN-boxed f64 object pointer, returns a NaN-boxed f64 array pointer. #[no_mangle] pub extern "C" fn js_object_get_own_property_names(obj_value: f64) -> f64 { + // An elements-backed Array-subclass instance: present indices, then + // `length`, then the shape's own string keys. + if crate::array::subclass_elements::backed_value(obj_value).is_some() { + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_h = scope.root_nanbox_f64(obj_value); + let (names, obj_value) = + obj_h.across_nanbox(|| js_object_get_own_property_names_shape(obj_value)); + if let Some((_, elements)) = crate::array::subclass_elements::backed_value(obj_value) { + let names_ptr = crate::value::js_nanbox_get_pointer(names) as *mut ArrayHeader; + let combined = unsafe { + crate::array::subclass_elements::prepend_index_keys(elements, names_ptr, true) + }; + return crate::value::js_nanbox_pointer(combined as i64); + } + return names; + } + js_object_get_own_property_names_shape(obj_value) +} + +/// [`js_object_get_own_property_names`] over the shape alone. +/// A receiver whose heap cell IS a `GC_TYPE_ARRAY`. `Array.isArray` is also +/// true for a `class X extends Array` instance, but that is an `ObjectHeader` +/// (#8953: reading it through the array key helpers dereferenced a null +/// cleaned pointer); its own keys come from the ordinary object walk — plus +/// the elements store, for the elements-backed form. +fn real_array_receiver(value: f64) -> bool { + let jv = JSValue::from_bits(value.to_bits()); + if !jv.is_pointer() { + return false; + } + let raw = (value.to_bits() & crate::value::POINTER_MASK) as usize; + unsafe { crate::value::addr_class::try_read_gc_header(raw) } + .is_some_and(|h| h.obj_type == crate::gc::GC_TYPE_ARRAY) +} + +fn js_object_get_own_property_names_shape(obj_value: f64) -> f64 { unsafe { // #2818: ToObject(null/undefined) throws TypeError, matching Node. let obj_jv = crate::JSValue::from_bits(obj_value.to_bits()); @@ -1427,7 +1473,7 @@ pub extern "C" fn js_object_get_own_property_names(obj_value: f64) -> f64 { } _ => Some(0), } - } else if crate::array::js_array_is_array(obj_value).to_bits() == TAG_TRUE_BITS { + } else if real_array_receiver(obj_value) { let ap = extract_obj_ptr(obj_value) as *const crate::array::ArrayHeader; Some(crate::array::js_array_length(ap)) } else { @@ -1435,7 +1481,7 @@ pub extern "C" fn js_object_get_own_property_names(obj_value: f64) -> f64 { }; if let Some(n) = n { let result = crate::array::js_array_alloc(n + 1); - if crate::array::js_array_is_array(obj_value).to_bits() == TAG_TRUE_BITS { + if real_array_receiver(obj_value) { let ap = extract_obj_ptr(obj_value) as *const crate::array::ArrayHeader; for i in 0..n { if super::has_own_helpers::array_own_key_present(ap, { 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 79c19b8b15..bfd8ffc49f 100644 --- a/crates/perry-runtime/src/object/field_get_set/enumeration.rs +++ b/crates/perry-runtime/src/object/field_get_set/enumeration.rs @@ -1043,6 +1043,27 @@ fn registered_buffer_enum(addr: usize, what: MapSetEnum) -> Option<*mut ArrayHea /// Otherwise (the common case), this returns the stored keys array directly. #[no_mangle] pub extern "C" fn js_object_keys(obj: *const ObjectHeader) -> *mut ArrayHeader { + // An elements-backed Array-subclass instance: its present indices come + // first (ascending, as strings), then the shape's own enumerable keys. + // `length` is non-enumerable and not in the shape, so it never appears. + if unsafe { crate::array::subclass_elements::backed(obj as usize) }.is_some() { + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_h = scope.root_raw_const_ptr(obj); + let (shape_keys, obj) = obj_h.across_const::(|| js_object_keys_shape(obj)); + if let Some((_, elements)) = + unsafe { crate::array::subclass_elements::backed(obj as usize) } + { + return unsafe { + crate::array::subclass_elements::prepend_index_keys(elements, shape_keys, false) + }; + } + return shape_keys; + } + js_object_keys_shape(obj) +} + +/// [`js_object_keys`] over the shape alone. +fn js_object_keys_shape(obj: *const ObjectHeader) -> *mut ArrayHeader { // #8149: a registered BUFFER receiver — node `Buffer`, `Uint8Array`, // `ArrayBuffer`, `SharedArrayBuffer` or `DataView`. Asked FIRST, above the // `is_valid_obj_ptr` guard: a `BufferHeader` is not an `ObjectHeader`, and 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 1c10f88b50..bde9b72270 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 @@ -30,6 +30,18 @@ pub extern "C" fn js_object_get_field_by_name( if let Some(value) = super::private_member_get_by_name(obj, key) { return JSValue::from_bits(value.to_bits()); } + // An elements-backed Array-subclass instance answers its indices and + // `length` from its store; an absent index falls through to the ordinary + // lookup, which reaches the prototype chain (the shape has no index keys). + if let Some((_, elements)) = unsafe { crate::array::subclass_elements::backed(obj as usize) } { + if let Some(elements_key) = unsafe { crate::array::subclass_elements::key_of_header(key) } { + if let Some(value) = + unsafe { crate::array::subclass_elements::get_by_key(elements, elements_key) } + { + return JSValue::from_bits(value.to_bits()); + } + } + } // #7341: the `.size` arm below calls two helpers that allocate, and every // arm AFTER it dereferences `obj` again. Shadow the parameter so that arm // can republish the post-collection address instead of leaving from-space 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 2e1cb30b9d..6e90a4a9ab 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 @@ -11,6 +11,18 @@ pub(crate) fn get_field_by_name_object_tail( obj: *const ObjectHeader, key: *const crate::StringHeader, ) -> JSValue { + // An elements-backed Array-subclass instance answers its indices and + // `length` from its store; an absent index falls through to the ordinary + // lookup, which reaches the prototype chain (the shape has no index keys). + if let Some((_, elements)) = unsafe { crate::array::subclass_elements::backed(obj as usize) } { + if let Some(elements_key) = unsafe { crate::array::subclass_elements::key_of_header(key) } { + if let Some(value) = + unsafe { crate::array::subclass_elements::get_by_key(elements, elements_key) } + { + return JSValue::from_bits(value.to_bits()); + } + } + } // Strip NaN-boxing tags if present (defensive: handle POINTER_TAG, UNDEFINED, NULL, etc.) let obj = { let bits = obj as u64; 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 8ebdfc10f1..8da38582cd 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 @@ -221,6 +221,15 @@ pub extern "C" fn js_object_has_property(obj: f64, key: f64) -> f64 { } }; let key_val = JSValue::from_bits(key.to_bits()); + if let Some((_, elements)) = crate::array::subclass_elements::backed_value(obj) { + if let Some(elements_key) = crate::array::subclass_elements::key_of_value(key) { + if unsafe { crate::array::subclass_elements::has_own_key(elements, elements_key) } { + return nanbox_true; + } + // Absent index: not own — the ordinary walk continues to the + // prototype chain (the shape carries no index keys). + } + } // ── #6748 fast path: ordinary heap object + string key ──────────────── // One GC-header read classifies the receiver. A `GC_TYPE_OBJECT` cannot diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index 1347d7a232..dd0ead0dbd 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -514,6 +514,22 @@ pub extern "C" fn js_object_get_field_ic_miss( // slot without repeating generic object dispatch. Both arms retain // their established helpers, making this a dispatch short-circuit // rather than a second implementation of either representation. + // An elements-backed Array-subclass instance answers its indices and + // `length` from its store; an absent index falls through to the ordinary + // lookup, which reaches the prototype chain (the shape has no index keys). + if let Some((_, elements)) = + unsafe { crate::array::subclass_elements::backed(obj as usize) } + { + if let Some(elements_key) = + unsafe { crate::array::subclass_elements::key_of_header(key) } + { + if let Some(value) = + unsafe { crate::array::subclass_elements::get_by_key(elements, elements_key) } + { + return value; + } + } + } if unsafe { key_bytes_are(key, b"length") } { match unsafe { gc_type_of(obj) } { Some(crate::gc::GC_TYPE_ARRAY) => { 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 3b01a5f35c..4447c63345 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -51,6 +51,23 @@ pub extern "C" fn js_object_set_field_by_name( // `prototype` property is non-writable, so both ordinary assignment and // a computed static field whose PropertyKey resolves to "prototype" must // fail instead of appending an ordinary shape slot. + // An elements-backed Array-subclass instance stores its indices and + // `length` in its store: no index key ever becomes a shape property. + if let Some((backed_obj, elements)) = + unsafe { crate::array::subclass_elements::backed(obj as usize) } + { + if let Some(elements_key) = unsafe { crate::array::subclass_elements::key_of_header(key) } { + unsafe { + crate::array::subclass_elements::set_by_key( + backed_obj, + elements, + elements_key, + value, + ) + }; + return; + } + } let obj_bits = obj as u64; let normalized_obj = if (obj_bits >> 48) == 0x7FFD { (obj_bits & crate::value::POINTER_MASK) as *mut ObjectHeader diff --git a/crates/perry-runtime/src/object/field_set_by_name/tail.rs b/crates/perry-runtime/src/object/field_set_by_name/tail.rs index 4dbacea616..7c5b1fee44 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/tail.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/tail.rs @@ -40,6 +40,23 @@ pub(crate) fn set_field_by_name_object_tail( key: *const crate::StringHeader, value: f64, ) { + // An elements-backed Array-subclass instance stores its indices and + // `length` in its store: no index key ever becomes a shape property. + if let Some((backed_obj, elements)) = + unsafe { crate::array::subclass_elements::backed(obj as usize) } + { + if let Some(elements_key) = unsafe { crate::array::subclass_elements::key_of_header(key) } { + unsafe { + crate::array::subclass_elements::set_by_key( + backed_obj, + elements, + elements_key, + value, + ) + }; + return; + } + } let scope = crate::gc::RuntimeHandleScope::new(); let obj_handle = scope.root_raw_mut_ptr(obj); let key_handle = scope.root_string_ptr(key); diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index aff75acb0d..cd36cac71a 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -75,7 +75,7 @@ mod data_view_registry; mod dataview_proto_thunks; mod date_proto_thunks; mod delete_rest; -mod descriptors; +pub(crate) mod descriptors; mod disposable_proto_thunks; pub(crate) mod exotic_expando; pub(crate) mod field_get_set; 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 8fb6bb9846..5b0d6afe8d 100644 --- a/crates/perry-runtime/src/object/object_ops/define_properties.rs +++ b/crates/perry-runtime/src/object/object_ops/define_properties.rs @@ -12,6 +12,7 @@ use super::*; /// on that so `const x = Object.defineProperties(...)` still binds `x`. #[no_mangle] pub extern "C" fn js_object_define_properties(target: f64, descriptors: f64) -> f64 { + crate::array::subclass_elements::deopt_value(target); // #2817: target must be an object (or class-ref). Node throws // `Object.defineProperties called on non-object` for primitives. // @@ -210,6 +211,7 @@ pub extern "C" fn js_object_define_properties(target: f64, descriptors: f64) -> /// + inherited property dispatch can consult it. #[no_mangle] pub extern "C" fn js_object_set_prototype_of(obj_value: f64, proto: f64) -> f64 { + crate::array::subclass_elements::deopt_value(obj_value); const TAG_NULL: u64 = 0x7FFC_0000_0000_0002; const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; let obj_bits = obj_value.to_bits(); 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 616a30f17e..7f18aaa72e 100644 --- a/crates/perry-runtime/src/object/object_ops/define_property.rs +++ b/crates/perry-runtime/src/object/object_ops/define_property.rs @@ -312,6 +312,14 @@ pub extern "C" fn js_object_define_property( key_value: f64, descriptor_value: f64, ) -> f64 { + // An index or `length` descriptor on an elements-backed Array-subclass + // instance leaves the elements representation for good (the shape-carried + // machinery models descriptors); other keys keep the store. + if crate::array::subclass_elements::backed_value(obj_value).is_some() + && crate::array::subclass_elements::key_of_value(key_value).is_some() + { + crate::array::subclass_elements::deopt_value(obj_value); + } unsafe { // #6748 follow-up: classify the receiver ONCE. A `GC_TYPE_OBJECT` that // is not an exotic cell (RegExp is the one OBJECT-typed exotic) cannot 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 ab40384314..61eb98ec69 100644 --- a/crates/perry-runtime/src/object/object_ops/has_own.rs +++ b/crates/perry-runtime/src/object/object_ops/has_own.rs @@ -71,6 +71,17 @@ pub extern "C" fn js_object_has_own(obj_value: f64, key_value: f64) -> f64 { if obj_js.is_undefined() || obj_js.is_null() { super::super::has_own_helpers::throw_to_object_nullish_type_error(); } + if let Some((_, elements)) = crate::array::subclass_elements::backed_value(obj_value) { + if let Some(elements_key) = crate::array::subclass_elements::key_of_value(key_value) { + return f64::from_bits( + if crate::array::subclass_elements::has_own_key(elements, elements_key) { + TAG_TRUE + } else { + TAG_FALSE + }, + ); + } + } // A POINTER_TAG registry handle (zlib stream, fetch Request/Response/ // Headers/Blob, …) is not an address and must never be dereferenced. Its diff --git a/crates/perry-runtime/src/object/object_ops_frozen.rs b/crates/perry-runtime/src/object/object_ops_frozen.rs index 700c1b646c..248510d3b8 100644 --- a/crates/perry-runtime/src/object/object_ops_frozen.rs +++ b/crates/perry-runtime/src/object/object_ops_frozen.rs @@ -128,6 +128,7 @@ unsafe fn test_integrity_level_proxy(obj_value: f64, frozen: bool) -> bool { #[no_mangle] pub extern "C" fn js_object_freeze(obj_value: f64) -> f64 { + crate::array::subclass_elements::deopt_value(obj_value); if crate::proxy::js_proxy_is_proxy(obj_value) != 0 { return unsafe { set_integrity_level_proxy(obj_value, /*frozen=*/ true) @@ -236,6 +237,7 @@ pub extern "C" fn js_object_freeze(obj_value: f64) -> f64 { /// existing key. Writable is preserved (sealed ≠ frozen). Returns the object. #[no_mangle] pub extern "C" fn js_object_seal(obj_value: f64) -> f64 { + crate::array::subclass_elements::deopt_value(obj_value); if crate::proxy::js_proxy_is_proxy(obj_value) != 0 { return unsafe { set_integrity_level_proxy(obj_value, /*frozen=*/ false) @@ -339,6 +341,7 @@ pub extern "C" fn js_object_seal(obj_value: f64) -> f64 { /// Object.preventExtensions(obj) — sets the no-extend flag. Returns the object. #[no_mangle] pub extern "C" fn js_object_prevent_extensions(obj_value: f64) -> f64 { + crate::array::subclass_elements::deopt_value(obj_value); // A Proxy is a small registered id, not a heap object — `extract_obj_ptr` // yields the fake pointer and `gc_header_for` would deref unmapped memory. // Route through the `[[PreventExtensions]]` trap; per spec throw a TypeError From e6228621e2e73c193c6ecd228430a68af8386854 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 15:02:41 +0200 Subject: [PATCH 04/13] runtime: exported dense index read answers from the elements store; drop the constant the gOPN fix left unused Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ --- crates/perry-runtime/src/array/subclass.rs | 10 +++++++++- crates/perry-runtime/src/object/descriptors.rs | 1 - 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/perry-runtime/src/array/subclass.rs b/crates/perry-runtime/src/array/subclass.rs index 1a8f67a51b..783deba4d6 100644 --- a/crates/perry-runtime/src/array/subclass.rs +++ b/crates/perry-runtime/src/array/subclass.rs @@ -1606,7 +1606,15 @@ pub extern "C" fn js_packed_arraylike_index_get(receiver: f64, index: f64, cache && header.gc_flags & crate::gc::GC_FLAG_FORWARDED == 0 { let obj = raw.cast::(); - if let Some(layout) = dense_layout_for_validated_object(obj) { + // Elements-backed instance: an in-bounds non-hole element + // answers directly; a hole continues to the complete + // dispatcher (prototype chain). + let elements = unsafe { super::subclass_elements::elements_of(obj) }; + if !elements.is_null() { + if let Some(value) = elements_index_get(elements, index_u32) { + return value; + } + } else if let Some(layout) = dense_layout_for_validated_object(obj) { // The codegen hit path handles both inline and // object-owned spill slots. In spill mode, publish a // class-wide dense-tail identity when the owner has diff --git a/crates/perry-runtime/src/object/descriptors.rs b/crates/perry-runtime/src/object/descriptors.rs index 35565161ba..5c65969c00 100644 --- a/crates/perry-runtime/src/object/descriptors.rs +++ b/crates/perry-runtime/src/object/descriptors.rs @@ -1463,7 +1463,6 @@ fn js_object_get_own_property_names_shape(obj_value: f64) -> f64 { // property names are the index names `"0".."len-1"` plus `"length"`. // Reading a bogus `keys_array` off their header segfaulted (#800). { - const TAG_TRUE_BITS: u64 = 0x7FFC_0000_0000_0004; let jv = JSValue::from_bits(obj_value.to_bits()); let n: Option = if jv.is_any_string() { let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; From a68bd40518d53b4bc1f88a2855d388f2c2e3d4c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 15:04:36 +0200 Subject: [PATCH 05/13] runtime: Object.values / Object.entries of an elements-backed Array subclass list its elements first Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ --- .../src/array/subclass_elements.rs | 100 ++++++++++++++++++ .../src/array/subclass_elements_tests.rs | 13 +++ .../src/object/field_get_set/enumeration.rs | 42 ++++++++ 3 files changed, 155 insertions(+) diff --git a/crates/perry-runtime/src/array/subclass_elements.rs b/crates/perry-runtime/src/array/subclass_elements.rs index d091fc7180..5d08dddec3 100644 --- a/crates/perry-runtime/src/array/subclass_elements.rs +++ b/crates/perry-runtime/src/array/subclass_elements.rs @@ -431,3 +431,103 @@ pub(crate) fn deopt_value(value: f64) { unsafe { deopt_to_shape(obj) }; } } + +/// A fresh values array: present elements (ascending), then every value of +/// `shape_values` in order (`Object.values`). +/// +/// # Safety +/// `elements` must be a live store; `shape_values` a live array (or null). +pub(crate) unsafe fn prepend_index_values( + elements: *const ArrayHeader, + shape_values: *mut ArrayHeader, +) -> *mut ArrayHeader { + let indices = own_index_keys(elements); + let scope = crate::gc::RuntimeHandleScope::new(); + let elements_h = scope.root_raw_const_ptr(elements); + let shape_h = scope.root_raw_mut_ptr(shape_values); + let out_h = scope.root_raw_mut_ptr(crate::array::js_array_alloc(0)); + let push = |value: f64| { + let (grown, _) = out_h.across_mut::(|| { + let out: *mut ArrayHeader = out_h.with_mut_ptr(|p| p); + crate::array::js_array_push_f64(out, value) + }); + out_h.set_raw_mut_ptr(grown); + }; + for index in indices { + let value = + elements_h.with_const_ptr(|e: *const ArrayHeader| f64::from_bits(slot_bits(e, index))); + push(value); + } + let shape_len = shape_h.with_mut_ptr(|p: *mut ArrayHeader| { + if p.is_null() { + 0 + } else { + crate::array::js_array_length(p) + } + }); + for i in 0..shape_len { + let value = shape_h.with_mut_ptr(|p: *mut ArrayHeader| crate::array::js_array_get(p, i)); + push(f64::from_bits(value.bits())); + } + out_h.with_mut_ptr(|p| p) +} + +/// A fresh entries array: `[String(index), element]` pairs for the present +/// elements (ascending), then every pair of `shape_entries` in order +/// (`Object.entries`). +/// +/// # Safety +/// `elements` must be a live store; `shape_entries` a live array (or null). +pub(crate) unsafe fn prepend_index_entries( + elements: *const ArrayHeader, + shape_entries: *mut ArrayHeader, +) -> *mut ArrayHeader { + let indices = own_index_keys(elements); + let scope = crate::gc::RuntimeHandleScope::new(); + let elements_h = scope.root_raw_const_ptr(elements); + let shape_h = scope.root_raw_mut_ptr(shape_entries); + let out_h = scope.root_raw_mut_ptr(crate::array::js_array_alloc(0)); + let push = |value: f64| { + let (grown, _) = out_h.across_mut::(|| { + let out: *mut ArrayHeader = out_h.with_mut_ptr(|p| p); + crate::array::js_array_push_f64(out, value) + }); + out_h.set_raw_mut_ptr(grown); + }; + for index in indices { + let name = index.to_string(); + let (key, _) = out_h.across_mut::(|| { + crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32) + }); + let key_h = scope.root_nanbox_f64(crate::value::js_nanbox_string(key as i64)); + let (pair, _) = out_h.across_mut::(|| crate::array::js_array_alloc(2)); + let pair_h = scope.root_raw_mut_ptr(pair); + let (pair, _) = pair_h.across_mut::(|| { + let pair: *mut ArrayHeader = pair_h.with_mut_ptr(|p| p); + crate::array::js_array_push_f64(pair, key_h.get_nanbox_f64()) + }); + pair_h.set_raw_mut_ptr(pair); + let value = + elements_h.with_const_ptr(|e: *const ArrayHeader| f64::from_bits(slot_bits(e, index))); + let (pair, _) = pair_h.across_mut::(|| { + let pair: *mut ArrayHeader = pair_h.with_mut_ptr(|p| p); + crate::array::js_array_push_f64(pair, value) + }); + pair_h.set_raw_mut_ptr(pair); + let pair_value = + pair_h.with_mut_ptr(|p: *mut ArrayHeader| crate::value::js_nanbox_pointer(p as i64)); + push(pair_value); + } + let shape_len = shape_h.with_mut_ptr(|p: *mut ArrayHeader| { + if p.is_null() { + 0 + } else { + crate::array::js_array_length(p) + } + }); + for i in 0..shape_len { + let value = shape_h.with_mut_ptr(|p: *mut ArrayHeader| crate::array::js_array_get(p, i)); + push(f64::from_bits(value.bits())); + } + out_h.with_mut_ptr(|p| p) +} diff --git a/crates/perry-runtime/src/array/subclass_elements_tests.rs b/crates/perry-runtime/src/array/subclass_elements_tests.rs index d49ea3dfd9..c892310944 100644 --- a/crates/perry-runtime/src/array/subclass_elements_tests.rs +++ b/crates/perry-runtime/src/array/subclass_elements_tests.rs @@ -249,6 +249,19 @@ fn the_property_funnel_answers_indices_and_length_from_the_store() { key_strings(crate::value::js_nanbox_get_pointer(names) as *const crate::array::ArrayHeader), vec!["1", "3", "length", "tag"] ); + // values / entries: present elements first, then the shape's `tag`. + let values = crate::object::js_object_values(obj()); + assert_eq!(crate::array::js_array_length(values), 3); + assert_eq!(crate::array::js_array_get(values, 0).as_number(), 11.0); + assert_eq!(crate::array::js_array_get(values, 1).as_number(), 13.0); + assert_eq!(crate::array::js_array_get(values, 2).as_number(), 7.0); + let entries = crate::object::js_object_entries(obj()); + assert_eq!(crate::array::js_array_length(entries), 3); + let first = crate::value::js_nanbox_get_pointer(f64::from_bits( + crate::array::js_array_get(entries, 0).bits(), + )) as *const crate::array::ArrayHeader; + assert_eq!(key_strings(first)[0], "1"); + assert_eq!(crate::array::js_array_get(first, 1).as_number(), 11.0); // Descriptors. let d = crate::object::js_object_get_own_property_descriptor(recv(), key_value("1")); let dobj = crate::value::js_nanbox_get_pointer(d) as *const ObjectHeader; 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 bfd8ffc49f..b86c326272 100644 --- a/crates/perry-runtime/src/object/field_get_set/enumeration.rs +++ b/crates/perry-runtime/src/object/field_get_set/enumeration.rs @@ -1426,6 +1426,27 @@ pub(crate) unsafe fn descriptor_marks_non_enumerable( /// Returns an array of the object's field values #[no_mangle] pub extern "C" fn js_object_values(obj: *const ObjectHeader) -> *mut ArrayHeader { + // An elements-backed Array-subclass instance: its present elements come + // first (ascending), then the shape's own enumerable properties. + if unsafe { crate::array::subclass_elements::backed(obj as usize) }.is_some() { + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_h = scope.root_raw_const_ptr(obj); + let (shape_part, obj) = + obj_h.across_const::(|| js_object_values_shape(obj)); + if let Some((_, elements)) = + unsafe { crate::array::subclass_elements::backed(obj as usize) } + { + return unsafe { + crate::array::subclass_elements::prepend_index_values(elements, shape_part) + }; + } + return shape_part; + } + js_object_values_shape(obj) +} + +/// [`js_object_values`] over the shape alone. +fn js_object_values_shape(obj: *const ObjectHeader) -> *mut ArrayHeader { // #8149: a registered BUFFER receiver — node `Buffer`, `Uint8Array`, // `ArrayBuffer`, `SharedArrayBuffer` or `DataView`. Asked FIRST, above the // `is_valid_obj_ptr` guard: a `BufferHeader` is not an `ObjectHeader`, and @@ -1589,6 +1610,27 @@ pub extern "C" fn js_object_values(obj: *const ObjectHeader) -> *mut ArrayHeader /// Returns an array where each element is a 2-element array [key, value] #[no_mangle] pub extern "C" fn js_object_entries(obj: *const ObjectHeader) -> *mut ArrayHeader { + // An elements-backed Array-subclass instance: its present elements come + // first (ascending), then the shape's own enumerable properties. + if unsafe { crate::array::subclass_elements::backed(obj as usize) }.is_some() { + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_h = scope.root_raw_const_ptr(obj); + let (shape_part, obj) = + obj_h.across_const::(|| js_object_entries_shape(obj)); + if let Some((_, elements)) = + unsafe { crate::array::subclass_elements::backed(obj as usize) } + { + return unsafe { + crate::array::subclass_elements::prepend_index_entries(elements, shape_part) + }; + } + return shape_part; + } + js_object_entries_shape(obj) +} + +/// [`js_object_entries`] over the shape alone. +fn js_object_entries_shape(obj: *const ObjectHeader) -> *mut ArrayHeader { // #8149: a registered BUFFER receiver — node `Buffer`, `Uint8Array`, // `ArrayBuffer`, `SharedArrayBuffer` or `DataView`. Asked FIRST, above the // `is_valid_obj_ptr` guard: a `BufferHeader` is not an `ObjectHeader`, and From 6acf82d1a57346e9f2a1ae50103b40d56260eb50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 15:11:32 +0200 Subject: [PATCH 06/13] codegen: elements-store probe ahead of the Array-subclass shape ICs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The object-backed `[i]` read tier (`arrlike.ic.*`) and the `.length` tier (`plen.ic.*`) now probe an elements-backed instance first: the object's meta word, then `ObjectMeta.elements` (word 12, const-asserted in the runtime), then — for the read — the inner array's header/bounds and slot (a hole or an out-of-bounds index goes to the complete dispatcher), or the inner `length` word. A probe miss is the shape-carried form and keeps the shape/family IC exactly as before, so nothing changes for it beyond two loads and two branches. IR tests pin both probes (the word-12 load, bounds+load blocks, the fallthrough to the shape IC). Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ --- .../expr/index_get/inline_dyn_typed_array.rs | 83 ++++++++++++++++++- .../src/expr/index_get_claim_tests.rs | 12 +++ .../src/expr/property_get/composed_ics.rs | 50 ++++++++++- .../src/expr/property_get/tests.rs | 23 +++++ crates/perry-runtime/src/object/mod.rs | 5 ++ 5 files changed, 171 insertions(+), 2 deletions(-) diff --git a/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs b/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs index 7211892e12..8968594c52 100644 --- a/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs +++ b/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs @@ -438,9 +438,90 @@ pub(super) fn lower_inline_dyn_typed_array_get( ctx.block() .cond_br(&header_ok, &object_brand_label, &object_miss_label); + // An elements-backed Array-subclass instance (`ObjectMeta.elements`, + // perry-runtime `array/subclass_elements.rs`): its indexed elements live + // in a real Array hanging off the meta record, so the read is the plain + // Array read on that inner array — no shape IC, no family token. A miss + // of this probe (no meta, no store) is the shape-carried form and keeps + // the IC below; an out-of-bounds index or a hole goes to the complete + // dispatcher (prototype chain). + let elem_meta_idx = ctx.new_block("arrlike.elem.meta"); + let elem_store_idx = ctx.new_block("arrlike.elem.store"); + let elem_bounds_idx = ctx.new_block("arrlike.elem.bounds"); + let elem_load_idx = ctx.new_block("arrlike.elem.load"); + let elem_value_idx = ctx.new_block("arrlike.elem.value"); + let elem_meta_label = ctx.block_label(elem_meta_idx); + let elem_store_label = ctx.block_label(elem_store_idx); + let elem_bounds_label = ctx.block_label(elem_bounds_idx); + let elem_load_label = ctx.block_label(elem_load_idx); + let elem_value_label = ctx.block_label(elem_value_idx); ctx.current_block = object_brand_idx; ctx.block() - .cond_br(&is_array, &object_array_guard_label, &object_shape_label); + .cond_br(&is_array, &object_array_guard_label, &elem_meta_label); + ctx.current_block = elem_meta_idx; + let elem_meta_addr = ctx.block().add(I64, &object_raw, &meta_offset); + let elem_meta_slot_ptr = ctx.block().inttoptr(I64, &elem_meta_addr); + let elem_meta_loaded = ctx.block().load( + if meta_ptr_size == 4 { I32 } else { I64 }, + &elem_meta_slot_ptr, + ); + let elem_meta_i64 = if meta_ptr_size == 4 { + ctx.block().zext(I32, &elem_meta_loaded, I64) + } else { + elem_meta_loaded + }; + let elem_has_meta = ctx.block().icmp_ne(I64, &elem_meta_i64, "0"); + ctx.block() + .cond_br(&elem_has_meta, &elem_store_label, &object_shape_label); + ctx.current_block = elem_store_idx; + let elem_meta_ptr = ctx.block().inttoptr(I64, &elem_meta_i64); + // `ObjectMeta.elements` is word 12 (offset 96; pinned by a const assert + // in perry-runtime `object/mod.rs`). + let elem_store_slot_ptr = ctx.block().gep(I64, &elem_meta_ptr, &[(I64, "12")]); + let elem_store_i64 = ctx.block().load(I64, &elem_store_slot_ptr); + let elem_has_store = ctx.block().icmp_ne(I64, &elem_store_i64, "0"); + ctx.block() + .cond_br(&elem_has_store, &elem_bounds_label, &object_shape_label); + ctx.current_block = elem_bounds_idx; + let elem_type_addr = ctx.block().sub(I64, &elem_store_i64, "8"); + let elem_type_ptr = ctx.block().inttoptr(I64, &elem_type_addr); + let elem_type = ctx.block().load(I8, &elem_type_ptr); + let elem_is_array = ctx.block().icmp_eq(I8, &elem_type, "1"); + let elem_flags_addr = ctx.block().sub(I64, &elem_store_i64, "7"); + let elem_flags_ptr = ctx.block().inttoptr(I64, &elem_flags_addr); + let elem_flags = ctx.block().load(I8, &elem_flags_ptr); + let elem_fwd = ctx.block().and(I8, &elem_flags, "128"); + let elem_not_fwd = ctx.block().icmp_eq(I8, &elem_fwd, "0"); + let elem_store_ptr = ctx.block().inttoptr(I64, &elem_store_i64); + let elem_length = ctx.block().load(I32, &elem_store_ptr); + let elem_length_i64 = ctx.block().zext(I32, &elem_length, I64); + let elem_in_bounds = ctx.block().icmp_ult(I64, &object_idx_i64, &elem_length_i64); + let elem_ok = ctx.block().and(I1, &elem_is_array, &elem_not_fwd); + let elem_ok = ctx.block().and(I1, &elem_ok, &elem_in_bounds); + ctx.block() + .cond_br(&elem_ok, &elem_load_label, &object_miss_label); + ctx.current_block = elem_load_idx; + let elem_bytes = ctx.block().shl(I64, &object_idx_i64, "3"); + let elem_elements_addr = ctx.block().add(I64, &elem_store_i64, "8"); + let elem_addr = ctx.block().add(I64, &elem_elements_addr, &elem_bytes); + let elem_ptr = ctx.block().inttoptr(I64, &elem_addr); + let elem_raw = ctx.block().load(DOUBLE, &elem_ptr); + let elem_bits = ctx.block().bitcast_double_to_i64(&elem_raw); + let elem_is_hole = ctx + .block() + .icmp_eq(I64, &elem_bits, crate::nanbox::TAG_HOLE_I64); + ctx.block() + .cond_br(&elem_is_hole, &object_miss_label, &elem_value_label); + ctx.current_block = elem_value_idx; + let elem_value = if coerce_slow_to_number { + ctx.block() + .call(DOUBLE, "js_number_coerce", &[(DOUBLE, &elem_raw)]) + } else { + elem_raw + }; + let elem_end_label = ctx.block().label.clone(); + ctx.block().br(&merge_label); + kind_incoming.push((elem_value, elem_end_label)); // Ordinary Array: the receiver tag and forwarding state were checked in // the predecessor. Reject descriptors or any process-wide prototype diff --git a/crates/perry-codegen/src/expr/index_get_claim_tests.rs b/crates/perry-codegen/src/expr/index_get_claim_tests.rs index 09e28734bf..6b0faf81d4 100644 --- a/crates/perry-codegen/src/expr/index_get_claim_tests.rs +++ b/crates/perry-codegen/src/expr/index_get_claim_tests.rs @@ -409,6 +409,18 @@ fn any_typed_dynamic_key_takes_the_numeric_tiers_when_it_is_an_array_index() { ir.contains("tav.get.brand") && ir.contains("arrlike.ic.family_token"), "an integer key must reach the inline typed-array and dense-subclass tiers:\n{ir}" ); + // The elements-backed subclass probe sits ahead of the shape IC: meta + // word → `ObjectMeta.elements` (word 12) → inner-array bounds → slot. + let store = super::class_field_barrier_tests::block_body(&ir, "arrlike.elem.store.") + .expect("the elements-store probe block exists"); + assert!( + store.contains("getelementptr i64, ptr %") && store.contains(", i64 12"), + "the probe must load ObjectMeta.elements at word 12:\n{store}" + ); + assert!( + ir.contains("arrlike.elem.bounds") && ir.contains("arrlike.elem.load"), + "the probe must bounds-check and load from the inner array:\n{ir}" + ); assert!( ir.contains("call double @js_array_get_index_or_string("), "non-index keys must keep the complete key route:\n{ir}" diff --git a/crates/perry-codegen/src/expr/property_get/composed_ics.rs b/crates/perry-codegen/src/expr/property_get/composed_ics.rs index 4ae3f0d7a1..b948ae3620 100644 --- a/crates/perry-codegen/src/expr/property_get/composed_ics.rs +++ b/crates/perry-codegen/src/expr/property_get/composed_ics.rs @@ -223,7 +223,54 @@ pub(super) fn emit_array_subclass_length_ic( let forwarded = ctx.block().and(I8, &gc_flags, "128"); let not_forwarded = ctx.block().icmp_eq(I8, &forwarded, "0"); let header_ok = ctx.block().and(I1, &is_object, ¬_forwarded); - ctx.block().cond_br(&header_ok, &shape_label, &miss_label); + // An elements-backed Array-subclass instance (`ObjectMeta.elements`): + // `length` is the inner Array's length word — no shape IC. A probe miss + // (no meta, no store) is the shape-carried form and keeps the IC below. + let elem_meta_idx = ctx.new_block("plen.elem.meta"); + let elem_store_idx = ctx.new_block("plen.elem.store"); + let elem_length_idx = ctx.new_block("plen.elem.length"); + let elem_meta_label = ctx.block_label(elem_meta_idx); + let elem_store_label = ctx.block_label(elem_store_idx); + let elem_length_label = ctx.block_label(elem_length_idx); + ctx.block() + .cond_br(&header_ok, &elem_meta_label, &miss_label); + ctx.current_block = elem_meta_idx; + let elem_meta_ptr_size: u64 = if crate::target_layout::target_is_ilp32(ctx.target_triple) { + 4 + } else { + 8 + }; + let elem_meta_offset = (crate::target_layout::object_header_size_bytes(ctx.target_triple) + - elem_meta_ptr_size) + .to_string(); + let elem_meta_addr = ctx.block().add(I64, recv_handle, &elem_meta_offset); + let elem_meta_slot_ptr = ctx.block().inttoptr(I64, &elem_meta_addr); + let elem_meta_loaded = ctx.block().load( + if elem_meta_ptr_size == 4 { I32 } else { I64 }, + &elem_meta_slot_ptr, + ); + let elem_meta_i64 = if elem_meta_ptr_size == 4 { + ctx.block().zext(I32, &elem_meta_loaded, I64) + } else { + elem_meta_loaded + }; + let elem_has_meta = ctx.block().icmp_ne(I64, &elem_meta_i64, "0"); + ctx.block() + .cond_br(&elem_has_meta, &elem_store_label, &shape_label); + ctx.current_block = elem_store_idx; + let elem_meta_ptr = ctx.block().inttoptr(I64, &elem_meta_i64); + // `ObjectMeta.elements` is word 12 (offset 96; const-asserted in the runtime). + let elem_store_slot_ptr = ctx.block().gep(I64, &elem_meta_ptr, &[(I64, "12")]); + let elem_store_i64 = ctx.block().load(I64, &elem_store_slot_ptr); + let elem_has_store = ctx.block().icmp_ne(I64, &elem_store_i64, "0"); + ctx.block() + .cond_br(&elem_has_store, &elem_length_label, &shape_label); + ctx.current_block = elem_length_idx; + let elem_store_ptr = ctx.block().inttoptr(I64, &elem_store_i64); + let elem_length_i32 = ctx.block().load(I32, &elem_store_ptr); + let elem_length = ctx.block().uitofp(I32, &elem_length_i32, DOUBLE); + let elem_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); ctx.current_block = shape_idx; let object_ptr = ctx.block().inttoptr(I64, recv_handle); @@ -358,6 +405,7 @@ pub(super) fn emit_array_subclass_length_ic( let length = ctx.block().phi( DOUBLE, &[ + (&elem_length, &elem_end), (&inline_length, &inline_end), (&spilled_length, &spill_end), (&miss_length, &miss_end), diff --git a/crates/perry-codegen/src/expr/property_get/tests.rs b/crates/perry-codegen/src/expr/property_get/tests.rs index 7c633bc593..03e8e6f41d 100644 --- a/crates/perry-codegen/src/expr/property_get/tests.rs +++ b/crates/perry-codegen/src/expr/property_get/tests.rs @@ -860,3 +860,26 @@ fn generic_non_size_read_has_no_collection_layout_load() { "only `.size` may grow the native collection fast path:\n{ir}" ); } + +/// The object-backed `.length` tier probes the elements-backed subclass store +/// first: meta word → `ObjectMeta.elements` (word 12) → the inner Array's +/// `length` word — and only then the shape/family IC. +#[test] +fn the_length_tier_probes_the_elements_store_before_the_shape_ic() { + let ir = emit_guarded_length_read(); + assert!( + ir.contains("plen.elem.meta") && ir.contains("plen.elem.length"), + "the elements probe must exist:\n{ir}" + ); + let store = super::super::class_field_barrier_tests::block_body(&ir, "plen.elem.store.") + .expect("the elements-store probe block exists"); + assert!( + store.contains("getelementptr i64, ptr %") && store.contains(", i64 12"), + "the probe must load ObjectMeta.elements at word 12:\n{store}" + ); + // A miss of the probe keeps the shape IC. + assert!( + store.contains("plen.ic.shape"), + "a missing store must fall through to the shape IC:\n{store}" + ); +} diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index cd36cac71a..c0228ad520 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -1663,6 +1663,11 @@ pub(crate) unsafe fn object_is_shaped(obj: *const ObjectHeader) -> bool { const _: () = assert!(std::mem::offset_of!(ObjectMeta, spill) == 32); const _: () = assert!(std::mem::offset_of!(ObjectMeta, array_subclass_named_prefix_token) == 48); const _: () = assert!(std::mem::offset_of!(ObjectMeta, array_tail_object_hot) == 56); +// The Array-subclass elements store: codegen's inline `elem.*` tiers load +// `ObjectHeader.meta` then this word (perry-codegen `expr/index_get` and +// `property_get/composed_ics.rs`). Keep in lock-step. +const _: () = assert!(std::mem::offset_of!(ObjectMeta, elements) == 96); +const _: () = assert!(std::mem::offset_of!(ObjectHeader, meta) == 8); const _: () = assert!(std::mem::size_of::() == 8); /// Fetch-or-allocate the per-object meta record. Caller must have already From d055c2c2ebb93223edee53e994a13e89c3787446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 15:14:40 +0200 Subject: [PATCH 07/13] codegen: spread super(...args) on an Array parent runs the subclass init; runtime: installed fill is non-enumerable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `class X extends Array { constructor(...a) { super(...a) } }` lowered the spread form through the registered-ancestor path, which has no entry for the native Array parent — the instance ended up with no `length` and no Array surface at all (`new X(2).length` → undefined, `JSON.stringify(x)` → `{}`), while the direct `super(n)` form ran `js_array_subclass_init_args`. The spread arm now hands the materialized argument array's elements to the same init. Under the elements gate the installed `fill` method is marked non-enumerable so it no longer leaks into `Object.keys` / `for..in` (#8953's third finding). Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ --- .../perry-codegen/src/expr/this_super_call.rs | 28 +++++++++++++++++++ .../src/node_stream_constructors/builders.rs | 11 +++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index 48fdc8bdd9..47a8ae9886 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -264,6 +264,34 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { .filter(|class| class.extends_expr.is_none() && !class.heritage_lexically_shadowed) .and_then(|class| class.extends_name.clone()) .filter(|parent| !ctx.classes.contains_key(parent.as_str())); + // `class X extends Array { constructor(...a) { super(...a) } }`: + // the Array parent has no registered constructor, so the spread + // form must run the same subclass init the direct `super(n)` + // form does (`lower_array_super_init`), handing it the + // materialized argument array's elements. Without this the + // instance had no `length` and no Array surface at all. + if async_parent.as_deref() == Some("Array") { + let len_i32 = ctx.block().call(I32, "js_array_length", &[(I64, &arr)]); + let len = ctx.block().zext(I32, &len_i32, I64); + let elems_addr = ctx.block().add(I64, &arr, "8"); + let elems_ptr = ctx.block().inttoptr(I64, &elems_addr); + let result = ctx.block().call( + DOUBLE, + "js_array_subclass_init_args", + &[ + (DOUBLE, &this_box), + (crate::types::PTR, &elems_ptr), + (I64, &len), + ], + ); + bind_derived_this_after_super(ctx); + crate::lower_call::apply_field_initializers_recursive( + ctx, + ¤t_class_name, + crate::lower_call::FieldInitMode::SelfOnly, + )?; + return Ok(result); + } if matches!( async_parent.as_deref(), Some("EventEmitterAsyncResource" | "AsyncLocalStorage" | "AsyncResource") diff --git a/crates/perry-runtime/src/node_stream_constructors/builders.rs b/crates/perry-runtime/src/node_stream_constructors/builders.rs index c7bd99190a..887eed3123 100644 --- a/crates/perry-runtime/src/node_stream_constructors/builders.rs +++ b/crates/perry-runtime/src/node_stream_constructors/builders.rs @@ -207,7 +207,16 @@ pub extern "C" fn js_array_subclass_init(this: f64, n: f64) -> f64 { crate::closure::js_register_closure_arity(ns_array_fill as *const u8, 1); let methods: [(&str, StubFn); 1] = [("fill", super::cast1(ns_array_fill))]; install_methods_on_existing_object(obj, this, &methods, &[]); - return this; + // `fill` is an own method here, but it must not show up as an + // enumerable own key (`Object.keys` / `for..in` / JSON): mark it + // non-enumerable like a prototype method would be. + let obj = raw_ptr_from_value(this_root.get_nanbox_f64()) as *mut ObjectHeader; + crate::object::descriptor_state::set_property_attrs( + obj as usize, + "fill".to_string(), + crate::object::descriptor_state::PropertyAttrs::new(true, false, true), + ); + return this_root.get_nanbox_f64(); } let length_key = crate::string::js_string_from_bytes(b"length".as_ptr(), 6); js_object_set_field_by_name(obj, length_key, len); From 66816ac745a2b678cba1143f49c756a0aa2250ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 15:27:44 +0200 Subject: [PATCH 08/13] codegen: spread super(...args) detects the Array parent the way the direct arm does; runtime: fast dense/elements index set before the keyed store; move the elements hot-entry helpers out of subclass.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * The spread-form Array arm keyed on `async_parent`, whose filter rejects a heritage that also carries `extends_expr` — which `extends Array` does — so the arm never fired and the constructor still went through `js_super_construct_apply`. Resolve the parent as the direct arm does (`extends_name`, lexically shadowed heritage excluded, no user class named Array). * `js_object_set_index_polymorphic` tries `array_subclass_fast_index_set` (dense or elements store) before the keyed store, which mints a key string per store — on wolf-ecs `packed[sparse[x]] = last` that was an allocation per swap under the elements gate. * `subclass.rs` was 2023 lines: the elements helpers now live in `subclass_elements.rs` (`ValidatedObjectReceiver` and the tail predicate are `pub(super)`); the `plen` probe reuses the existing `meta_offset` so the codegen header-size callsite census is unchanged. Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ --- .../src/expr/property_get/composed_ics.rs | 31 ++--- .../perry-codegen/src/expr/this_super_call.rs | 12 +- crates/perry-runtime/src/array/mod.rs | 11 +- crates/perry-runtime/src/array/subclass.rs | 121 ++++-------------- .../src/array/subclass_elements.rs | 92 +++++++++++++ .../src/object/polymorphic_index.rs | 9 ++ 6 files changed, 151 insertions(+), 125 deletions(-) diff --git a/crates/perry-codegen/src/expr/property_get/composed_ics.rs b/crates/perry-codegen/src/expr/property_get/composed_ics.rs index b948ae3620..041ab6f050 100644 --- a/crates/perry-codegen/src/expr/property_get/composed_ics.rs +++ b/crates/perry-codegen/src/expr/property_get/composed_ics.rs @@ -223,6 +223,14 @@ pub(super) fn emit_array_subclass_length_ic( let forwarded = ctx.block().and(I8, &gc_flags, "128"); let not_forwarded = ctx.block().icmp_eq(I8, &forwarded, "0"); let header_ok = ctx.block().and(I1, &is_object, ¬_forwarded); + let meta_ptr_size: u64 = if crate::target_layout::target_is_ilp32(ctx.target_triple) { + 4 + } else { + 8 + }; + let meta_offset = (crate::target_layout::object_header_size_bytes(ctx.target_triple) + - meta_ptr_size) + .to_string(); // An elements-backed Array-subclass instance (`ObjectMeta.elements`): // `length` is the inner Array's length word — no shape IC. A probe miss // (no meta, no store) is the shape-carried form and keeps the IC below. @@ -235,21 +243,13 @@ pub(super) fn emit_array_subclass_length_ic( ctx.block() .cond_br(&header_ok, &elem_meta_label, &miss_label); ctx.current_block = elem_meta_idx; - let elem_meta_ptr_size: u64 = if crate::target_layout::target_is_ilp32(ctx.target_triple) { - 4 - } else { - 8 - }; - let elem_meta_offset = (crate::target_layout::object_header_size_bytes(ctx.target_triple) - - elem_meta_ptr_size) - .to_string(); - let elem_meta_addr = ctx.block().add(I64, recv_handle, &elem_meta_offset); + let elem_meta_addr = ctx.block().add(I64, recv_handle, &meta_offset); let elem_meta_slot_ptr = ctx.block().inttoptr(I64, &elem_meta_addr); let elem_meta_loaded = ctx.block().load( - if elem_meta_ptr_size == 4 { I32 } else { I64 }, + if meta_ptr_size == 4 { I32 } else { I64 }, &elem_meta_slot_ptr, ); - let elem_meta_i64 = if elem_meta_ptr_size == 4 { + let elem_meta_i64 = if meta_ptr_size == 4 { ctx.block().zext(I32, &elem_meta_loaded, I64) } else { elem_meta_loaded @@ -299,15 +299,6 @@ pub(super) fn emit_array_subclass_length_ic( let exact_match = ctx.block().icmp_eq(I64, &live_key, &cached_key); ctx.block().cond_br(&exact_match, &slot_label, &miss_label); - let meta_ptr_size: u64 = if crate::target_layout::target_is_ilp32(ctx.target_triple) { - 4 - } else { - 8 - }; - let meta_offset = (crate::target_layout::object_header_size_bytes(ctx.target_triple) - - meta_ptr_size) - .to_string(); - ctx.current_block = family_meta_idx; let family_meta_addr = ctx.block().add(I64, recv_handle, &meta_offset); let family_meta_slot_ptr = ctx.block().inttoptr(I64, &family_meta_addr); diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index 47a8ae9886..51be11827a 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -270,7 +270,17 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // form does (`lower_array_super_init`), handing it the // materialized argument array's elements. Without this the // instance had no `length` and no Array surface at all. - if async_parent.as_deref() == Some("Array") { + // Resolved the way the direct `super(n)` arm resolves its parent + // (`extends_name`, a lexically shadowed heritage excluded): the + // heritage of `class X extends Array` also carries `extends_expr`, + // which the `async_parent` filter above rejects. + let array_parent = ctx + .classes + .get(¤t_class_name) + .filter(|class| !class.heritage_lexically_shadowed) + .and_then(|class| class.extends_name.as_deref()) + .is_some_and(|parent| parent == "Array" && !ctx.classes.contains_key("Array")); + if array_parent { let len_i32 = ctx.block().call(I32, "js_array_length", &[(I64, &arr)]); let len = ctx.block().zext(I32, &len_i32, I64); let elems_addr = ctx.block().add(I64, &arr, "8"); diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index 730fdb71eb..4bfada28f3 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -187,11 +187,12 @@ pub(crate) use indexing_support::test_swap_array_index_fast_path_invalidated; // points, plus the Array-exotic `length` maintenance the generic OBJECT index // store needs for a `class X extends Array` receiver. pub(crate) use self::subclass::{ - array_object_set_length, array_subclass_fast_index_get, array_subclass_fast_length, - array_subclass_fast_length_with_ic, array_subclass_named_prefix_token_for_slot, - array_subclass_tail_descriptors_are_plain, clear_array_subclass_named_prefix_token, - clear_packed_subclass_numeric_proof, is_array_subclass_class_id, is_array_subclass_value, - note_array_subclass_index_write, note_packed_subclass_spill_store, + array_object_set_length, array_subclass_fast_index_get, array_subclass_fast_index_set, + array_subclass_fast_length, array_subclass_fast_length_with_ic, + array_subclass_named_prefix_token_for_slot, array_subclass_tail_descriptors_are_plain, + clear_array_subclass_named_prefix_token, clear_packed_subclass_numeric_proof, + is_array_subclass_class_id, is_array_subclass_value, note_array_subclass_index_write, + note_packed_subclass_spill_store, }; // Issue #1572 — flatten helpers reused by `node_stream::ns_iter_flat_map` // so an `async function*` mapper return is driven through the iterator diff --git a/crates/perry-runtime/src/array/subclass.rs b/crates/perry-runtime/src/array/subclass.rs index 783deba4d6..65bfac2406 100644 --- a/crates/perry-runtime/src/array/subclass.rs +++ b/crates/perry-runtime/src/array/subclass.rs @@ -96,9 +96,9 @@ struct DenseSubclassLayout { /// Array-subclass mutation reuse that single header read for brand, layout, /// and frozen/sealed/no-extend checks. #[derive(Clone, Copy)] -struct ValidatedObjectReceiver { - object: *const ObjectHeader, - object_flags: u16, +pub(super) struct ValidatedObjectReceiver { + pub(super) object: *const ObjectHeader, + pub(super) object_flags: u16, } /// Read the per-instance prototype-divergence bit after the caller has already @@ -352,32 +352,6 @@ fn validated_object_receiver_for_value(value: f64) -> Option Option<*mut ArrayHeader> { - let elements = unsafe { super::subclass_elements::elements_of(receiver.object) }; - (!elements.is_null()).then_some(elements) -} - -/// In-bounds, non-hole element of the inner array; `None` sends the caller -/// to the same prototype-chain fallback a hole in the shape-carried form does. -#[inline] -fn elements_index_get(elements: *const ArrayHeader, index: u32) -> Option { - unsafe { - if index >= (*elements).length { - return None; - } - let slot = (elements as *const u8) - .add(std::mem::size_of::()) - .cast::() - .add(index as usize); - let bits = *slot; - (bits != crate::value::TAG_HOLE).then_some(f64::from_bits(bits)) - } -} - /// Resolve the cached dense layout after the caller has proved that `obj` is /// a live, non-forwarded ordinary object. Every rejected Array-subclass brand, /// descriptor, hole, or prototype case returns `None`. @@ -881,8 +855,8 @@ fn nonnegative_u32_length(value: JSValue) -> Option { /// semantics; descriptor/prototype-divergent shapes decline above. #[inline] pub(crate) fn array_subclass_fast_length(value: f64) -> Option { - if let Some(elements) = - validated_object_receiver_for_value(value).and_then(|r| elements_for_validated(&r)) + if let Some(elements) = validated_object_receiver_for_value(value) + .and_then(|r| super::subclass_elements::elements_for_validated(&r)) { return Some(f64::from(unsafe { (*elements).length })); } @@ -899,8 +873,8 @@ pub(crate) fn array_subclass_fast_length(value: f64) -> Option { /// into the cache, so moving GC needs neither a root nor a rewrite hook. #[inline] pub(crate) fn array_subclass_fast_length_with_ic(value: f64, cache: *mut u64) -> Option { - if let Some(elements) = - validated_object_receiver_for_value(value).and_then(|r| elements_for_validated(&r)) + if let Some(elements) = validated_object_receiver_for_value(value) + .and_then(|r| super::subclass_elements::elements_for_validated(&r)) { // No shape layout to publish: the IC words describe inline slots, // and an elements-backed receiver has none for `length`. @@ -931,7 +905,7 @@ pub(crate) fn array_subclass_fast_length_with_ic(value: f64, cache: *mut u64) -> pub(crate) fn array_subclass_fast_length_raw(arr: *const ArrayHeader) -> Option { let raw = (arr as u64 & crate::value::POINTER_MASK) as usize; let receiver = validated_object_receiver(raw)?; - if let Some(elements) = elements_for_validated(&receiver) { + if let Some(elements) = super::subclass_elements::elements_for_validated(&receiver) { return Some(f64::from(unsafe { (*elements).length })); } let layout = dense_layout_for_validated_object(receiver.object)?; @@ -945,10 +919,10 @@ pub(crate) fn array_subclass_fast_length_raw(arr: *const ArrayHeader) -> Option< /// proof when a length-only grow created holes without changing the shape. #[inline] pub(crate) fn array_subclass_fast_index_get(value: f64, index: u32) -> Option { - if let Some(elements) = - validated_object_receiver_for_value(value).and_then(|r| elements_for_validated(&r)) + if let Some(elements) = validated_object_receiver_for_value(value) + .and_then(|r| super::subclass_elements::elements_for_validated(&r)) { - return elements_index_get(elements, index); + return super::subclass_elements::elements_index_get(elements, index); } let (obj, layout) = dense_layout_for_value(value)?; dense_index_get_with_layout(obj, layout, index) @@ -961,8 +935,8 @@ pub(crate) fn array_subclass_fast_index_get_raw( ) -> Option { let raw = (arr as u64 & crate::value::POINTER_MASK) as usize; let receiver = validated_object_receiver(raw)?; - if let Some(elements) = elements_for_validated(&receiver) { - return elements_index_get(elements, index); + if let Some(elements) = super::subclass_elements::elements_for_validated(&receiver) { + return super::subclass_elements::elements_index_get(elements, index); } let layout = dense_layout_for_validated_object(receiver.object)?; dense_index_get_with_layout(receiver.object, layout, index) @@ -1157,7 +1131,7 @@ pub(crate) fn array_subclass_tail_descriptors_are_plain( } #[inline(always)] -fn mutation_receiver_allows_plain_tail(object_flags: u16) -> bool { +pub(super) fn mutation_receiver_allows_plain_tail(object_flags: u16) -> bool { object_flags & (crate::gc::OBJ_FLAG_FROZEN | crate::gc::OBJ_FLAG_SEALED | crate::gc::OBJ_FLAG_NO_EXTEND) == 0 @@ -1176,59 +1150,6 @@ pub(crate) fn array_subclass_fast_index_set(receiver: f64, index: u32, value: f6 array_subclass_fast_index_set_validated(receiver, index, value) } -/// Elements-backed `receiver[index] = value` for an in-bounds index or the -/// appending index (`== length`); anything else (holes past the end, a -/// frozen/sealed/non-extensible receiver) declines to the generic path. -fn elements_index_set(receiver: &ValidatedObjectReceiver, index: u32, value: f64) -> Option { - let elements = elements_for_validated(receiver)?; - if !mutation_receiver_allows_plain_tail(receiver.object_flags) { - return Some(false); - } - let length = unsafe { (*elements).length }; - if index < length { - crate::array::js_array_set_f64(elements, index, value); - return Some(true); - } - if index == length { - return elements_push(receiver, value).map(|_| true); - } - Some(false) -} - -/// Elements-backed append: the owner is rooted across the (possibly -/// re-allocating) push and the new head is written back through the -/// barriered meta slot. Returns the new length. -fn elements_push(receiver: &ValidatedObjectReceiver, value: f64) -> Option { - let elements = elements_for_validated(receiver)?; - if !mutation_receiver_allows_plain_tail(receiver.object_flags) { - return None; - } - let obj = receiver.object as *mut ObjectHeader; - unsafe { - let scope = crate::gc::RuntimeHandleScope::new(); - let obj_handle = scope.root_raw_mut_ptr(obj); - let value_root = scope.root_nanbox_f64(value); - let (grown, obj) = obj_handle.across_mut::(|| { - crate::array::js_array_push_f64(elements, value_root.get_nanbox_f64()) - }); - let current = super::subclass_elements::elements_of(obj); - if grown != current { - super::subclass_elements::set_elements_head(obj, grown); - } - Some(f64::from((*grown).length)) - } -} - -/// Elements-backed `pop`: the inner array's own pop (no allocation, so no -/// rooting), declining like `elements_push` on a non-plain receiver. -fn elements_pop(receiver: &ValidatedObjectReceiver) -> Option { - let elements = elements_for_validated(receiver)?; - if !mutation_receiver_allows_plain_tail(receiver.object_flags) { - return None; - } - Some(crate::array::js_array_pop_f64(elements)) -} - #[inline] pub(crate) fn array_subclass_fast_index_set_raw( arr: *const ArrayHeader, @@ -1248,7 +1169,7 @@ fn array_subclass_fast_index_set_validated( index: u32, value: f64, ) -> bool { - if let Some(done) = elements_index_set(&receiver, index, value) { + if let Some(done) = super::subclass_elements::elements_index_set(&receiver, index, value) { return done; } let obj = receiver.object; @@ -1331,8 +1252,8 @@ fn array_subclass_fast_push_one_validated( value: f64, proven_u31: Option, ) -> Option { - if elements_for_validated(&receiver).is_some() { - return elements_push(&receiver, value); + if super::subclass_elements::elements_for_validated(&receiver).is_some() { + return super::subclass_elements::elements_push(&receiver, value); } let obj = receiver.object; let layout = dense_layout_for_validated_object(obj)?; @@ -1459,8 +1380,8 @@ pub(crate) fn array_subclass_fast_pop_raw(arr: *const ArrayHeader) -> Option Option { - if elements_for_validated(&receiver).is_some() { - return elements_pop(&receiver); + if super::subclass_elements::elements_for_validated(&receiver).is_some() { + return super::subclass_elements::elements_pop(&receiver); } let obj = receiver.object; let layout = dense_layout_for_validated_object(obj)?; @@ -1611,7 +1532,9 @@ pub extern "C" fn js_packed_arraylike_index_get(receiver: f64, index: f64, cache // dispatcher (prototype chain). let elements = unsafe { super::subclass_elements::elements_of(obj) }; if !elements.is_null() { - if let Some(value) = elements_index_get(elements, index_u32) { + if let Some(value) = + super::subclass_elements::elements_index_get(elements, index_u32) + { return value; } } else if let Some(layout) = dense_layout_for_validated_object(obj) { diff --git a/crates/perry-runtime/src/array/subclass_elements.rs b/crates/perry-runtime/src/array/subclass_elements.rs index 5d08dddec3..070b6635bd 100644 --- a/crates/perry-runtime/src/array/subclass_elements.rs +++ b/crates/perry-runtime/src/array/subclass_elements.rs @@ -13,6 +13,8 @@ use crate::array::ArrayHeader; use crate::object::ObjectHeader; +use super::subclass::{mutation_receiver_allows_plain_tail, ValidatedObjectReceiver}; + /// `PERRY_ARRAY_SUBCLASS_ELEMENTS=1|on|true` — off while the property entry /// points are being routed; the default flips once the semantics suite is green. #[inline] @@ -531,3 +533,93 @@ pub(crate) unsafe fn prepend_index_entries( } out_h.with_mut_ptr(|p| p) } + +// --------------------------------------------------------------------------- +// Hot-entry helpers used by `super::subclass` (moved here to keep that file +// under the size gate). +// --------------------------------------------------------------------------- + +/// The elements store of an elements-backed instance (`super::subclass_elements`), +/// or `None` for the shape-carried representation. Non-null only when the +/// store was installed at construction, so no gate check is needed here. +#[inline] +pub(super) fn elements_for_validated( + receiver: &ValidatedObjectReceiver, +) -> Option<*mut ArrayHeader> { + let elements = unsafe { elements_of(receiver.object) }; + (!elements.is_null()).then_some(elements) +} + +/// In-bounds, non-hole element of the inner array; `None` sends the caller +/// to the same prototype-chain fallback a hole in the shape-carried form does. +#[inline] +pub(super) fn elements_index_get(elements: *const ArrayHeader, index: u32) -> Option { + unsafe { + if index >= (*elements).length { + return None; + } + let slot = (elements as *const u8) + .add(std::mem::size_of::()) + .cast::() + .add(index as usize); + let bits = *slot; + (bits != crate::value::TAG_HOLE).then_some(f64::from_bits(bits)) + } +} + +/// Elements-backed `receiver[index] = value` for an in-bounds index or the +/// appending index (`== length`); anything else (holes past the end, a +/// frozen/sealed/non-extensible receiver) declines to the generic path. +pub(super) fn elements_index_set( + receiver: &ValidatedObjectReceiver, + index: u32, + value: f64, +) -> Option { + let elements = elements_for_validated(receiver)?; + if !mutation_receiver_allows_plain_tail(receiver.object_flags) { + return Some(false); + } + let length = unsafe { (*elements).length }; + if index < length { + crate::array::js_array_set_f64(elements, index, value); + return Some(true); + } + if index == length { + return elements_push(receiver, value).map(|_| true); + } + Some(false) +} + +/// Elements-backed append: the owner is rooted across the (possibly +/// re-allocating) push and the new head is written back through the +/// barriered meta slot. Returns the new length. +pub(super) fn elements_push(receiver: &ValidatedObjectReceiver, value: f64) -> Option { + let elements = elements_for_validated(receiver)?; + if !mutation_receiver_allows_plain_tail(receiver.object_flags) { + return None; + } + let obj = receiver.object as *mut ObjectHeader; + unsafe { + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_handle = scope.root_raw_mut_ptr(obj); + let value_root = scope.root_nanbox_f64(value); + let (grown, obj) = obj_handle.across_mut::(|| { + crate::array::js_array_push_f64(elements, value_root.get_nanbox_f64()) + }); + let current = elements_of(obj); + if grown != current { + set_elements_head(obj, grown); + } + Some(f64::from((*grown).length)) + } +} + +/// Elements-backed `pop`: the inner array's own pop (no allocation, so no +/// rooting), declining like `elements_push` on a non-plain receiver. +pub(super) fn elements_pop(receiver: &ValidatedObjectReceiver) -> Option { + let elements = elements_for_validated(receiver)?; + if !mutation_receiver_allows_plain_tail(receiver.object_flags) { + return None; + } + Some(crate::array::js_array_pop_f64(elements)) +} diff --git a/crates/perry-runtime/src/object/polymorphic_index.rs b/crates/perry-runtime/src/object/polymorphic_index.rs index 1248757dfa..accf224d09 100644 --- a/crates/perry-runtime/src/object/polymorphic_index.rs +++ b/crates/perry-runtime/src/object/polymorphic_index.rs @@ -485,6 +485,15 @@ pub extern "C" fn js_object_set_index_polymorphic(obj_handle: i64, idx: f64, val // which handles shape transitions, frozen/sealed/extensible checks, // overflow into out-of-line storage, and accessor descriptors. // Keep the receiver/key alive because the setter can allocate and the + // An Array-subclass instance with an in-bounds or appending index: + // the dense/elements store takes it without minting a key string + // (the keyed path below allocates one per store — on the wolf-ecs + // `packed[sparse[x]] = last` that was an allocation per swap). + if let Some(index) = numeric_key_u32_index(idx) { + if crate::array::array_subclass_fast_index_set(boxed, index, value) { + return; + } + } // Array-subclass post-step below must observe their evacuated values. let scope = crate::gc::RuntimeHandleScope::new(); let recv_h = scope.root_nanbox_f64(boxed); From 4f2a8255db1391b271f1933ff4cb87a5ec9249e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 16:03:03 +0200 Subject: [PATCH 09/13] runtime: counted-loop guard admits an elements-backed receiver as a plain-array loop over its inner store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the store installed the instance has no dense SHAPE layout, so `js_packed_arraylike_loop_guard` declined every versioned loop and the reads fell back to string-keyed property lookups — 6x slower on the wolf-ecs twins (perf: `from_utf8` 11.8%, by-name get 15%, `build_dense_layout` rebuilt per call 4.9%). The guard (and the live-revalidation entry) now resolve such a receiver to its inner array and run the ordinary kind-1 admission on it: length/capacity/descriptor and raw-f64 proofs are the plain-array ones, the live address handed to the loop is the inner array's, and each revalidation re-resolves the store from the receiver — so a re-allocating append inside the body side-exits on the stale facts exactly as a grown plain Array does. The by-name elements intercept also gained a one-byte pre-filter (a leading ASCII digit or `l`, length <= 10) so ordinary named properties on these receivers no longer pay a UTF-8 decode and an index parse. Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ --- crates/perry-runtime/src/array/subclass.rs | 2 +- .../src/array/subclass_elements.rs | 13 ++++ .../src/array/subclass_elements_tests.rs | 75 +++++++++++++++++++ .../src/array/subclass_loop_guard.rs | 39 ++++++++++ 4 files changed, 128 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/array/subclass.rs b/crates/perry-runtime/src/array/subclass.rs index 65bfac2406..74d793193a 100644 --- a/crates/perry-runtime/src/array/subclass.rs +++ b/crates/perry-runtime/src/array/subclass.rs @@ -15,7 +15,7 @@ use crate::object::ObjectHeader; use crate::value::JSValue; #[path = "subclass_loop_guard.rs"] -mod loop_guard; +pub(super) mod loop_guard; // The loop-guard entry points are exported C symbols; only the unit tests // reach them through Rust paths. #[cfg(test)] diff --git a/crates/perry-runtime/src/array/subclass_elements.rs b/crates/perry-runtime/src/array/subclass_elements.rs index 070b6635bd..73adce63d1 100644 --- a/crates/perry-runtime/src/array/subclass_elements.rs +++ b/crates/perry-runtime/src/array/subclass_elements.rs @@ -140,6 +140,19 @@ pub(crate) unsafe fn key_of_header(key: *const crate::StringHeader) -> Option 10 { + return None; + } + let first = *(key as *const u8).add(std::mem::size_of::()); + if !first.is_ascii_digit() && first != b'l' { + return None; + } crate::object::has_own_helpers::str_from_string_header(key).and_then(key_of_str) } diff --git a/crates/perry-runtime/src/array/subclass_elements_tests.rs b/crates/perry-runtime/src/array/subclass_elements_tests.rs index c892310944..954586dba6 100644 --- a/crates/perry-runtime/src/array/subclass_elements_tests.rs +++ b/crates/perry-runtime/src/array/subclass_elements_tests.rs @@ -337,3 +337,78 @@ fn freeze_deopts_to_the_shape_carried_form() { None ); } + +/// The counted-loop guard admits an elements-backed instance as a PLAIN-ARRAY +/// loop over its inner array: kind 1, the inner array's live address, and a +/// revalidation that re-resolves the (possibly re-allocated) store from the +/// receiver — this is what keeps versioned loops off the string-keyed +/// property path. +#[test] +fn the_counted_loop_guard_admits_an_elements_backed_receiver_as_its_inner_array() { + let _triggers = crate::gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + crate::gc::register_runtime_handle_root_scanner_for_tests(); + let class_id = 0x0074_8699; + crate::object::js_register_class_parent(class_id, CLASS_ID_ARRAY); + let obj = js_object_alloc(class_id, 2); + let scope = crate::gc::RuntimeHandleScope::new(); + let recv_h = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(obj as i64)); + unsafe { install_elements(live_obj(recv_h.get_nanbox_f64()), 0) }; + let recv = || recv_h.get_nanbox_f64(); + for i in 0..8u32 { + assert!(super::subclass::array_subclass_fast_push_one(recv(), f64::from(i)).is_some()); + } + + let mut facts = [0u64; 7]; + let live = super::subclass::loop_guard::js_packed_arraylike_loop_guard_live( + recv(), + -1.0, + 0, + facts.as_mut_ptr(), + ); + assert_ne!(live, 0, "an elements-backed receiver must be admitted"); + assert_eq!(facts[0], 1, "admitted as a plain-array loop"); + assert_eq!( + live as usize, + unsafe { elements_of(live_obj(recv())) } as usize, + "the live address is the inner array" + ); + assert_eq!(facts[6], 8, "the live-length bound is the inner length"); + let revalidated = super::subclass::loop_guard::js_packed_arraylike_loop_revalidate_live( + recv(), + -1.0, + 0, + facts.as_ptr(), + ); + assert_eq!(revalidated, live, "revalidation resolves the same store"); + + // An append inside the loop body re-allocates the store. The recorded + // facts describe the OLD array, so revalidation takes the side exit (0), + // exactly as it does for a grown plain Array — and a fresh guard call + // then admits the current store. + for i in 8..64u32 { + assert!(super::subclass::array_subclass_fast_push_one(recv(), f64::from(i)).is_some()); + } + let grown = unsafe { elements_of(live_obj(recv())) }; + assert_ne!(grown as usize, live as usize, "the appends re-allocated"); + assert_eq!( + super::subclass::loop_guard::js_packed_arraylike_loop_revalidate_live( + recv(), + -1.0, + 0, + facts.as_ptr(), + ), + 0, + "stale facts must side-exit" + ); + let live2 = super::subclass::loop_guard::js_packed_arraylike_loop_guard_live( + recv(), + -1.0, + 0, + facts.as_mut_ptr(), + ); + assert_eq!( + live2 as usize, grown as usize, + "re-admission sees the new store" + ); + assert_eq!(facts[6], 64); +} diff --git a/crates/perry-runtime/src/array/subclass_loop_guard.rs b/crates/perry-runtime/src/array/subclass_loop_guard.rs index c170048f04..99c720ea5b 100644 --- a/crates/perry-runtime/src/array/subclass_loop_guard.rs +++ b/crates/perry-runtime/src/array/subclass_loop_guard.rs @@ -15,6 +15,32 @@ use super::*; /// dense_prefix|inline_bound<<32, bound)`. Kind 1 is an ArrayHeader and kind 2 /// is an ObjectHeader Array subclass. A zero return leaves every semantic case /// to the unchanged generic loop. +/// Resolve an elements-backed Array-subclass receiver to its inner array +/// (`None` for every other receiver, including the shape-carried subclass +/// form, which keeps the kind-2 admission below). +#[inline] +fn elements_loop_source( + raw: *const u8, + header: &'static crate::gc::GcHeader, +) -> Option<(*const u8, &'static crate::gc::GcHeader)> { + if header.obj_type != crate::gc::GC_TYPE_OBJECT + || header.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 + { + return None; + } + let elements = + unsafe { crate::array::subclass_elements::elements_of(raw.cast::()) } + as *const u8; + if elements.is_null() { + return None; + } + let elements_header = + unsafe { crate::value::addr_class::try_read_gc_header(elements as usize) }?; + (elements_header.obj_type == crate::gc::GC_TYPE_ARRAY + && elements_header.gc_flags & crate::gc::GC_FLAG_FORWARDED == 0) + .then_some((elements, elements_header)) +} + fn packed_arraylike_loop_guard( receiver: f64, bound: f64, @@ -64,6 +90,14 @@ fn packed_arraylike_loop_guard( source }; let header = unsafe { crate::value::addr_class::try_read_gc_header(raw as usize) }?; + // An elements-backed Array-subclass instance (`super::subclass_elements`) + // keeps its elements in a real Array hanging off the meta record, so the + // loop is a PLAIN-ARRAY loop over that inner array: resolve to it and let + // the ordinary kind-1 admission below prove length/capacity/descriptors + // and (for a numeric mode) the raw-f64 bit. The live address the caller + // reads through is the inner array's, and the revalidation entry + // re-resolves it from the receiver on every iteration. + let (raw, header) = elements_loop_source(raw, header).unwrap_or((raw, header)); if header.obj_type == crate::gc::GC_TYPE_ARRAY { if header._reserved & crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS != 0 @@ -292,6 +326,11 @@ pub extern "C" fn js_packed_arraylike_loop_revalidate_live( else { return 0; }; + // Re-resolve an elements-backed receiver to its CURRENT inner array: an + // append inside the loop body may have re-allocated it, and the meta slot + // is the authority. + let (source, source_header) = + elements_loop_source(source, source_header).unwrap_or((source, source_header)); let raw = if source_header.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 { if source_header.obj_type != crate::gc::GC_TYPE_ARRAY { return 0; From c96362ceb78b2654afd5d20758a585832260d8f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 16:11:49 +0200 Subject: [PATCH 10/13] runtime: no own fill on an elements-backed instance; serve it through the subclass method funnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marking the installed `fill` non-enumerable put a property DESCRIPTOR on every instance, so `OBJ_FLAG_HAS_DESCRIPTORS` was set — and the codegen class-field inline guard rejects such receivers, sending every `arch.change` / `arch.mask` read to `js_object_get_field_ic_miss` (gdb on the gate-on wolf-ecs twin: the miss key is `change`, called from `_archChange`). That was the whole 6x gate-on regression, not the elements representation. An elements-backed instance now installs no own `fill` at all — which is also what node's object shape looks like (`fill` lives on `Array.prototype`) — and `array_object_method` serves the inherited `fill` through `js_array_fill_generic`. Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ --- crates/perry-runtime/src/array/subclass.rs | 15 +++++++++++++ .../src/node_stream_constructors/builders.rs | 21 +++++++------------ 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/crates/perry-runtime/src/array/subclass.rs b/crates/perry-runtime/src/array/subclass.rs index 74d793193a..a17a3e4d0a 100644 --- a/crates/perry-runtime/src/array/subclass.rs +++ b/crates/perry-runtime/src/array/subclass.rs @@ -1801,6 +1801,21 @@ pub(crate) fn array_object_method(recv: f64, method: &str, args: &[f64]) -> Opti if let Some(value) = array_subclass_fast_pop(recv) { return Some(value); } + } else if method == "fill" { + // `Array.prototype.fill` over the receiver's own `length` + indexed + // properties. An elements-backed instance has no own `fill` method + // (see `js_array_subclass_init`), so this funnel is where the + // inherited one is served. + return Some(crate::array::js_array_fill_generic( + recv, + args.first() + .copied() + .unwrap_or(f64::from_bits(crate::value::TAG_UNDEFINED)), + i32::from(args.len() > 1), + args.get(1).copied().unwrap_or(0.0), + i32::from(args.len() > 2), + args.get(2).copied().unwrap_or(0.0), + )); } let (ptr, len) = (args.as_ptr(), args.len()); if let Some(result) = super::generic::run_object_mutator(recv, method, ptr, len) { diff --git a/crates/perry-runtime/src/node_stream_constructors/builders.rs b/crates/perry-runtime/src/node_stream_constructors/builders.rs index 887eed3123..46df4af13f 100644 --- a/crates/perry-runtime/src/node_stream_constructors/builders.rs +++ b/crates/perry-runtime/src/node_stream_constructors/builders.rs @@ -202,20 +202,13 @@ pub extern "C" fn js_array_subclass_init(this: f64, n: f64) -> f64 { unsafe { crate::array::subclass_elements::install_elements(obj, len.min(u32::MAX as f64) as u32) }; - let this = this_root.get_nanbox_f64(); - let obj = raw_ptr_from_value(this) as *mut ObjectHeader; - crate::closure::js_register_closure_arity(ns_array_fill as *const u8, 1); - let methods: [(&str, StubFn); 1] = [("fill", super::cast1(ns_array_fill))]; - install_methods_on_existing_object(obj, this, &methods, &[]); - // `fill` is an own method here, but it must not show up as an - // enumerable own key (`Object.keys` / `for..in` / JSON): mark it - // non-enumerable like a prototype method would be. - let obj = raw_ptr_from_value(this_root.get_nanbox_f64()) as *mut ObjectHeader; - crate::object::descriptor_state::set_property_attrs( - obj as usize, - "fill".to_string(), - crate::object::descriptor_state::PropertyAttrs::new(true, false, true), - ); + // No own `fill` method: an own property would show up in + // `Object.getOwnPropertyNames` (node inherits `fill` from + // `Array.prototype`), and hiding it with a descriptor would set + // `OBJ_FLAG_HAS_DESCRIPTORS` on every instance — which the codegen + // class-field inline guard rejects, sending every field read to the + // IC miss (measured: 6x on the wolf-ecs twins). The elements-backed + // instance serves `fill` through `array_object_method` instead. return this_root.get_nanbox_f64(); } let length_key = crate::string::js_string_from_bytes(b"length".as_ptr(), 6); From a44fade01d60f54898018f862508874d677c0627 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 16:18:15 +0200 Subject: [PATCH 11/13] runtime: keep the own fill on an elements-backed instance (no descriptor) Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ --- .../src/node_stream_constructors/builders.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/perry-runtime/src/node_stream_constructors/builders.rs b/crates/perry-runtime/src/node_stream_constructors/builders.rs index 46df4af13f..de5a937947 100644 --- a/crates/perry-runtime/src/node_stream_constructors/builders.rs +++ b/crates/perry-runtime/src/node_stream_constructors/builders.rs @@ -202,13 +202,18 @@ pub extern "C" fn js_array_subclass_init(this: f64, n: f64) -> f64 { unsafe { crate::array::subclass_elements::install_elements(obj, len.min(u32::MAX as f64) as u32) }; - // No own `fill` method: an own property would show up in - // `Object.getOwnPropertyNames` (node inherits `fill` from - // `Array.prototype`), and hiding it with a descriptor would set - // `OBJ_FLAG_HAS_DESCRIPTORS` on every instance — which the codegen - // class-field inline guard rejects, sending every field read to the - // IC miss (measured: 6x on the wolf-ecs twins). The elements-backed - // instance serves `fill` through `array_object_method` instead. + // The Array surface the instance relies on, installed exactly as in + // the shape-carried form. It must NOT be hidden behind a property + // descriptor: that sets `OBJ_FLAG_HAS_DESCRIPTORS` on every instance, + // which the codegen class-field inline guard rejects — every field + // read then takes the IC miss (measured: 6x on the wolf-ecs twins). + // `fill` showing up in `getOwnPropertyNames` is the pre-existing + // divergence tracked in #8953, unchanged by the elements store. + let this = this_root.get_nanbox_f64(); + let obj = raw_ptr_from_value(this) as *mut ObjectHeader; + crate::closure::js_register_closure_arity(ns_array_fill as *const u8, 1); + let methods: [(&str, StubFn); 1] = [("fill", super::cast1(ns_array_fill))]; + install_methods_on_existing_object(obj, this, &methods, &[]); return this_root.get_nanbox_f64(); } let length_key = crate::string::js_string_from_bytes(b"length".as_ptr(), 6); From 35e21b17161eb7f567ebaf655f47b470303a3768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 16:36:00 +0200 Subject: [PATCH 12/13] runtime: the named-prefix token covers elements-backed instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `js_object_get_field_ic_miss` refuses to prime the PIC for a receiver that has own descriptors unless a named-prefix token proves the declared fields sit at fixed slots. `js_array_subclass_init` installs `fill` with a descriptor, so every Array-subclass instance needs that token — and the builder derived it from the dense SHAPE layout, which `build_dense_layout` cannot produce for an elements-backed instance (it locates `length` by name, and the store owns `length`). The PIC was therefore never primed and every declared-field read missed forever: 17.8M misses on `change`/`sset`/`mask` in a 2 s wolf-ecs run (per-key census), which is the whole remaining gate-on regression. An elements-backed instance has NO numeric keys, so its named prefix is the entire shape — the strongest form of the same proof. The builder now takes `(element_base, dense_prefix_len, length_slot)` either from the dense layout (shape-carried form, unchanged) or synthesises "no numeric tail" for the elements form, where the declared-prefix comparison and the accessor check remain the authority and a class that declares its own `length` field is excluded. Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ --- crates/perry-runtime/src/array/subclass.rs | 89 +++++++++++++++------- 1 file changed, 61 insertions(+), 28 deletions(-) diff --git a/crates/perry-runtime/src/array/subclass.rs b/crates/perry-runtime/src/array/subclass.rs index a17a3e4d0a..094f174442 100644 --- a/crates/perry-runtime/src/array/subclass.rs +++ b/crates/perry-runtime/src/array/subclass.rs @@ -439,23 +439,43 @@ pub(crate) unsafe fn array_subclass_named_prefix_token_for_slot( if declared_keys.is_null() { return 0; } - let shape_id = (*obj).parent_class_id; - let cache_key = dense_cache_key(class_id, shape_id); - let layout = cached_dense_layout(cache_key).or_else(|| { - let layout = build_dense_layout(obj)?; - publish_dense_layout(cache_key, layout); - Some(layout) - }); - let Some(layout) = layout else { - return 0; + // An elements-backed instance (`super::subclass_elements`) has NO numeric + // keys and no `length` property in its shape, so `build_dense_layout` + // (which locates `length` by name) cannot describe it — and without a + // token the descriptor-bearing IC-miss path below never primes the PIC, + // leaving every declared-field read to miss forever (measured: 17.8M + // misses on `change`/`sset`/`mask` in a 2 s wolf-ecs run). Its named + // prefix is simply the WHOLE shape: the strongest form of the same + // proof, validated by the identical declared-prefix comparison below. + let elements_backed = !super::subclass_elements::elements_of(obj).is_null(); + let (element_base, dense_prefix_len, length_slot) = if elements_backed { + (u32::MAX, 0, u32::MAX) + } else { + let shape_id = (*obj).parent_class_id; + let cache_key = dense_cache_key(class_id, shape_id); + let layout = cached_dense_layout(cache_key).or_else(|| { + let layout = build_dense_layout(obj)?; + publish_dense_layout(cache_key, layout); + Some(layout) + }); + let Some(layout) = layout else { + return 0; + }; + ( + layout.element_base, + layout.dense_prefix_len, + layout.length_slot, + ) }; // Descriptor-bearing Array subclasses cannot use the ordinary exact-shape // raw-load PIC even while empty: their unrelated `length` descriptor sends // them through the descriptor arm. Admit the fully validated named prefix // before the first numeric key exists as well. `element_base` is the first // prospective numeric slot and `dense_prefix_len == 0` proves there is no - // tail yet; the complete-prefix equality below remains the authority. - if requested_slot >= layout.element_base as usize { + // tail yet; the complete-prefix equality below remains the authority. An + // elements-backed instance has no numeric slot at all (`u32::MAX`), so + // only the declared-prefix bound below applies to it. + if requested_slot >= element_base as usize { return 0; } let cached = (*meta).array_subclass_named_prefix_token; @@ -475,11 +495,17 @@ pub(crate) unsafe fn array_subclass_named_prefix_token_for_slot( crate::object::keys_array_dense_slots(declared_keys as *const ArrayHeader); let current_count = (shape.logical_key_count as usize).min(current_physical_len); let declared_count = (declared_count as usize).min(declared_physical_len); - if current_slots.is_null() - || declared_slots.is_null() - || declared_count > current_count - || layout.element_base as usize + layout.dense_prefix_len as usize != current_count - { + if current_slots.is_null() || declared_slots.is_null() || declared_count > current_count { + return 0; + } + // Every key is either in the named prefix or in the numeric tail. An + // elements-backed instance has no tail, so the requested slot must simply + // be a declared one; the shape-carried form keeps the exact partition. + if elements_backed { + if requested_slot >= declared_count { + return 0; + } + } else if element_base as usize + dense_prefix_len as usize != current_count { return 0; } @@ -523,25 +549,32 @@ pub(crate) unsafe fn array_subclass_named_prefix_token_for_slot( // existing slot; otherwise the exact missing names must follow the // declared prefix in that order. Anything else is instance-specific. let declared_count = declared_count as u32; - let expected_length_slot = if let Some(slot) = declared_length_slot { - slot - } else { - declared_count - }; - if layout.length_slot != expected_length_slot { - return 0; - } let mut expected_runtime_names: [&[u8]; 2] = [&[]; 2]; let mut expected_runtime_count = 0usize; - if declared_length_slot.is_none() { - expected_runtime_names[expected_runtime_count] = b"length"; - expected_runtime_count += 1; + if !elements_backed { + // The shape-carried form carries `length` as an own property, at the + // declared slot when the class declared that name and appended + // otherwise. + let expected_length_slot = declared_length_slot.unwrap_or(declared_count); + if length_slot != expected_length_slot { + return 0; + } + if declared_length_slot.is_none() { + expected_runtime_names[expected_runtime_count] = b"length"; + expected_runtime_count += 1; + } + } else if declared_length_slot.is_some() { + // A class that declares its own `length` field is not modelled by the + // elements store (the store owns `length`); keep it off this token. + return 0; } if !declared_fill { expected_runtime_names[expected_runtime_count] = b"fill"; expected_runtime_count += 1; } - if layout.element_base != declared_count.saturating_add(expected_runtime_count as u32) { + if !elements_backed + && element_base != declared_count.saturating_add(expected_runtime_count as u32) + { return 0; } for (offset, expected) in expected_runtime_names[..expected_runtime_count] From a41a67ba1da28d98bb8327a78fac69c5ee932603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 17:24:23 +0200 Subject: [PATCH 13/13] fix(array): reuse the shared StringHeader payload helper Open-coding the offset a second time raises the string-payload-access ratchet; `crate::object::string_header_payload` already exists for this. --- crates/perry-runtime/src/array/subclass_elements.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/array/subclass_elements.rs b/crates/perry-runtime/src/array/subclass_elements.rs index 73adce63d1..2b68960cd8 100644 --- a/crates/perry-runtime/src/array/subclass_elements.rs +++ b/crates/perry-runtime/src/array/subclass_elements.rs @@ -149,7 +149,7 @@ pub(crate) unsafe fn key_of_header(key: *const crate::StringHeader) -> Option 10 { return None; } - let first = *(key as *const u8).add(std::mem::size_of::()); + let first = *crate::object::string_header_payload(key); if !first.is_ascii_digit() && first != b'l' { return None; }