Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions changelog.d/8986-imported-private-brands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Imported private brands are installed once, by the defining module's standalone constructor.

A metadata-only imported class stub is now identified explicitly, so importing a class that uses private elements no longer re-runs brand installation in the importing module. Re-branding produced a second brand for the same class, so a private access that had been valid through one import path failed through the other.

Covers direct imports, accessors, local and imported subclasses, same-module branding, and genuine duplicate initialization.
5 changes: 5 additions & 0 deletions changelog.d/8987-array-subclass-enumeration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Fixed

- Array-subclass enumeration now matches Node: `Object.keys` and `for...in`
report only enumerable indices, while `Object.getOwnPropertyNames` also
reports `length`; inherited `Array.prototype.fill` no longer leaks as an own key.
30 changes: 30 additions & 0 deletions changelog.d/8988-read-stub-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
A megamorphic stub cache for dynamic string-keyed property reads — the read
twin of #8965/#8977's write stub, 2-way set-associative from the start.

A hit skips `js_object_get_field_by_name`'s fast-lane guard chain (address
class, interned-key flag, arena classification, header type/flags/class,
keys-array validation) and the read-plan probe. That probe matters more than
its own cost suggests: the plan's epoch is bumped by the incremental collector
at loop-poll cadence, so on a steady read loop it is repeatedly cold and falls
through to a shape-index hash lookup.

Interleaved A/B, min-of-21: pure property read 17 → 15 ms (−12% min, −17%
mean), computed-key read 23 → 22 ms, combined overwrite 41 → 39 ms, write
unchanged.

**Safety.** Entries store CONTENT bits, never an address, so a key that dies
and has its address recycled cannot produce a false hit; keys that do not fit
the inline form are not cached. Every hit re-validates heap-object type,
not-forwarded, blocking flags, class id, and the receiver's CURRENT shape
token — which pins the exact key set *and order*, so a match means the cached
slot still names this key. The probe sits after the `process.env` and Proxy
arms, which keep their own semantics, and the stub is only primed from inside
the lane, once the receiver is proved ordinary.

Because a wrong-slot read would be silent corruption rather than a crash, this
carries an adversarial differential: a delete that changes the shape under a
cached slot, an accessor defined over a cached data slot, prototype fallback
after the own property is deleted, `Object.freeze`, the same two keys inserted
in opposite orders, and a 300-key object whose slots live in the overflow
store. Output is byte-identical to node on all of it. Suite 2779 passed;
private-member output identical to base.
29 changes: 29 additions & 0 deletions crates/perry-codegen/src/lower_call/field_init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -693,10 +693,39 @@ pub(crate) fn apply_field_initializers_recursive(
None => init_pairs.push((field.name.clone(), init, field.is_private)),
}
}
// #8962: an IMPORTED class installs nothing here. Its whole
// field-initializer phase — public field writes, private-field adds AND
// the shared private brand — is baked into the defining module's
// standalone `<prefix>__<class>_constructor`, which `codegen/method.rs`
// emits for exactly that reason ("At the `new ImportedClass(...)` call
// site, `lower_new` applies initializers against the imported class
// stub — which has none"). That premise holds for FIELDS because the
// stub flattens every field to `is_private: false` with `init: None`,
// so the worst this loop could do was write `undefined` into a slot the
// real constructor overwrites moments later.
//
// It does NOT hold for the private BRAND. The stub copies private
// METHOD and accessor names verbatim (it needs them to resolve dispatch
// symbols), and `has_private_instance_brand` is defined purely over
// `#`-prefixed method/getter/setter names — so a stub answers `true` and
// this site emitted `js_private_brand_add` at the importing module's
// `new`, on top of the one the defining module's constructor emits.
// Installing a class's brand twice on one object is the observable
// error PrivateMethodOrAccessorAdd requires, so the runtime threw
// "Cannot initialize private elements twice on the same object" out of
// `new Hono()` — any imported class with a private method or accessor,
// whether constructed directly or reached as an ancestor through
// `AncestorsOnly`.
//
// Suppressing BOTH flags (not just the brand) is what restores the
// `continue` below for a stub whose only private elements are methods:
// for a stub the two predicates are the same question, since its fields
// are never private.
let (class_has_private_elements, class_has_private_brand) = ctx
.classes
.get(&class_name_in_chain)
.copied()
.filter(|class| !class.is_imported_stub())
.map(|class| {
(
class.has_private_instance_elements(),
Expand Down
21 changes: 21 additions & 0 deletions crates/perry-hir/src/ir/decl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,27 @@ pub struct Class {
}

impl Class {
/// True for the metadata-only stub `compile_module` synthesizes for a class
/// IMPORTED from another module (`perry-codegen/src/codegen/mod.rs`, "Build
/// a stub Class with the minimum fields the codegen needs").
///
/// A stub is a NAME TABLE, not a class: it carries member names so the
/// importing module can resolve dispatch symbols, and carries no bodies, no
/// field initializers and no constructor. Everything a construction
/// actually *does* — field initializers, private-field adds, the private
/// brand — is baked into the defining module's standalone
/// `<prefix>__<class>_constructor` instead (`codegen/method.rs`,
/// `is_constructor_method`), precisely because the stub has none of it.
///
/// `id == 0` is the marker: the driver hands out class ids from 1
/// (`run_pipeline.rs`: "Start at 1, 0 is reserved for \"no parent\"") and
/// every local class takes its id from `LoweringContext::fresh_class`, so
/// the stub built at `codegen/mod.rs` ("id: 0, // imported — no local
/// ClassId") is the only `Class` in a module's class table with id 0.
pub fn is_imported_stub(&self) -> bool {
self.id == 0
}

/// Whether construction installs any instance-private element.
pub fn has_private_instance_elements(&self) -> bool {
self.fields.iter().any(|field| field.is_private)
Expand Down
12 changes: 6 additions & 6 deletions crates/perry-runtime/src/array/subclass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -543,11 +543,11 @@ pub(crate) unsafe fn array_subclass_named_prefix_token_for_slot(
}
}

// `js_array_subclass_init` installs two canonical own properties that are
// absent from most class allocation shapes: `length` and the generic
// `fill` method. If a class declared either name, init overwrites its
// existing slot; otherwise the exact missing names must follow the
// declared prefix in that order. Anything else is instance-specific.
// The legacy shape-carried representation installs `length` and its
// compatibility `fill` closure after the declared prefix. The default
// elements-backed representation inherits `fill` from `Array.prototype`
// and has no runtime names in its shape. Anything else is
// instance-specific.
let declared_count = declared_count as u32;
let mut expected_runtime_names: [&[u8]; 2] = [&[]; 2];
let mut expected_runtime_count = 0usize;
Expand All @@ -568,7 +568,7 @@ pub(crate) unsafe fn array_subclass_named_prefix_token_for_slot(
// elements store (the store owns `length`); keep it off this token.
return 0;
}
if !declared_fill {
if !elements_backed && !declared_fill {
expected_runtime_names[expected_runtime_count] = b"fill";
expected_runtime_count += 1;
}
Expand Down
30 changes: 6 additions & 24 deletions crates/perry-runtime/src/node_stream_constructors/builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,15 +162,11 @@ pub extern "C" fn js_event_emitter_async_resource_subclass_init(this: f64, optio
/// `super(n)` for a source-compiled `class X extends Array` (e.g. lru-cache's
/// `ZeroArray`: `class ZeroArray extends Array { constructor(n){ super(n);
/// this.fill(0) } }`). Perry models the subclass instance as a plain object,
/// not a real exotic Array, so `super(n)` otherwise left it length-less with no
/// Array methods. Size it (`length = ToLength(n)`, a visible own property the
/// generic array-like helpers read) and install the Array surface the instance
/// relies on — currently `fill`, which delegates to `js_array_fill_generic`
/// (it operates on the receiver's own `length` + indexed properties, exactly
/// what an array-like object exposes). Indexed get/set already work as ordinary
/// object properties. Mirrors `js_event_emitter_subclass_init` (#5494); the
/// codegen `super()` lowering for an `Array` parent calls this. Additional
/// Array methods can be added to `array_subclass_methods` as bundles need them.
/// not a real exotic Array, so `super(n)` initializes its elements store. In
/// the default representation, inherited methods resolve through
/// `Array.prototype` and are not stamped as enumerable own properties. The
/// legacy shape-carried kill switch retains its old compatibility closure.
/// The codegen `super()` lowering calls this entry point.
#[no_mangle]
pub extern "C" fn js_array_subclass_init(this: f64, n: f64) -> f64 {
let raw = raw_ptr_from_value(this);
Expand Down Expand Up @@ -202,18 +198,6 @@ 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)
};
// 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, 3);
let methods: [(&str, StubFn); 1] = [("fill", super::cast3(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);
Expand Down Expand Up @@ -258,9 +242,7 @@ pub unsafe extern "C" fn js_array_subclass_init_args(
this.get_nanbox_f64()
}

/// `Array.prototype.fill`-equivalent installed on an Array-subclass instance:
/// fills the receiver's own indexed slots `0..length` with `value`. Delegates
/// to the generic array-like fill (which reads `length` off the receiver).
/// Legacy shape-carried compatibility closure for `Array.prototype.fill`.
pub(super) extern "C" fn ns_array_fill(
closure: *const ClosureHeader,
value: f64,
Expand Down
37 changes: 29 additions & 8 deletions crates/perry-runtime/src/object/field_get_set/accessors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,29 @@ pub(crate) unsafe fn string_index_value(
}
}

/// Resolve an inherited `Array.prototype` property for an Array-subclass
/// instance after its own fields and class-declared methods have missed.
/// An explicit per-instance prototype replaces the ordinary class chain and
/// therefore suppresses this implicit fallback.
pub(crate) unsafe fn array_subclass_prototype_field(
obj: *const ObjectHeader,
key: *const crate::StringHeader,
) -> Option<JSValue> {
if obj.is_null()
|| key.is_null()
|| super::super::prototype_chain::object_static_prototype(obj as usize).is_some()
|| !crate::array::is_array_subclass_class_id((*obj).class_id)
{
return None;
}
let key_ptr = crate::object::string_header_payload(key);
let key_len = (*key).byte_len as usize;
let name = std::str::from_utf8(std::slice::from_raw_parts(key_ptr, key_len)).ok()?;
// `array_prototype_property_value` copies `name` before its first
// allocation and roots the receiver across the prototype lookup.
array_prototype_property_value(name, obj as usize)
}

pub(crate) unsafe fn array_prototype_property_value(
name: &str,
receiver_addr: usize,
Expand All @@ -625,6 +648,8 @@ pub(crate) unsafe fn array_prototype_property_value(
let name_copy = super::HeapKeyBytes::copy_of(name.as_bytes());
let name: &str = std::str::from_utf8_unchecked(name_copy.as_bytes());

let scope = crate::gc::RuntimeHandleScope::new();
let receiver_h = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(receiver_addr as i64));
let ctor = super::super::js_get_global_this_builtin_value(b"Array".as_ptr(), 5);
let ctor_value = JSValue::from_bits(ctor.to_bits());
if !ctor_value.is_pointer() {
Expand All @@ -636,15 +661,11 @@ pub(crate) unsafe fn array_prototype_property_value(
if !proto_value.is_pointer() {
return None;
}
// #7498: `js_string_from_bytes` ALLOCATES, so `Array.prototype` and the
// receiver cannot be carried across it as bare `usize`s — and the key it
// produces is itself a fresh heap string this function then hands to two
// more calls that can collect (`js_object_get_field_by_name` runs getters;
// `default_object_prototype_property_value` interns another key). Root all
// three and read each back at its point of use.
let scope = crate::gc::RuntimeHandleScope::new();
// #7498: the receiver is rooted before the allocating global lookup above;
// `Array.prototype` and the fresh key are rooted before the calls below,
// which can collect (`js_object_get_field_by_name` runs getters and
// `default_object_prototype_property_value` interns another key).
let proto_h = scope.root_nanbox_f64(proto);
let receiver_h = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(receiver_addr as i64));
let key_h = scope.root_nanbox_f64(crate::value::nanbox_string_key(
crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32),
));
Expand Down
67 changes: 67 additions & 0 deletions crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,55 @@ pub extern "C" fn js_object_get_field_by_name(
}
}
}
// Megamorphic read stub. Primed below once the lane has proved this
// receiver ordinary, so a hit only has to re-prove the properties that can
// change: heap-object type, not forwarded, no blocking flags, a real class
// id, and the receiver's CURRENT shape token. The token pins the exact key
// set and order, so a match means the cached slot still names this key; a
// stale entry misses rather than resolving to the wrong property.
//
// Sits after the process.env and Proxy arms above, which have their own
// semantics and must keep them, and before the lane's guard chain plus the
// read-plan probe — which is what a hit is here to skip. The plan's epoch
// is bumped by the collector at loop-poll cadence, so on a steady read loop
// it is repeatedly cold and falls through to a shape-index hash lookup.
unsafe {
if let Some(key_bits) = super::super::read_stub::read_stub_key_bits(key) {
let addr = obj as usize;
if let Some(gc) = crate::value::addr_class::try_read_gc_header(addr) {
const STUB_BLOCKING: u16 =
crate::gc::OBJ_FLAG_HAS_DESCRIPTORS | crate::gc::OBJ_FLAG_TYPED_ARRAY_PROTO;
if gc.obj_type == crate::gc::GC_TYPE_OBJECT
&& gc.gc_flags & crate::gc::GC_FLAG_FORWARDED == 0
&& gc._reserved & STUB_BLOCKING == 0
{
let o = addr as *const ObjectHeader;
let class_id = (*o).class_id;
if class_id != 0
&& class_id != super::super::native_module::NATIVE_MODULE_CLASS_ID
{
if let Some(token) = super::super::read_stub::receiver_shape_token(o) {
if let Some(slot) =
super::super::read_stub::read_stub_probe(token, key_bits)
{
let live = crate::object::object_live_slot_count(o);
let limit =
std::cmp::max(live, crate::object::INLINE_SLOT_FLOOR as u32);
if slot < limit {
return super::accessors::js_object_get_field(o, slot);
}
if let Some(bits) = super::super::overflow_get(addr, slot as usize)
{
return JSValue::from_bits(bits);
}
}
}
}
}
}
}
}

// FAST LANE (store-plan-cache follow-up): resolve an OWN data field on a
// provably-plain arena class instance with no rooting scope, no
// exotic-registry probes, and no key hashing. Every gate proves a property
Expand Down Expand Up @@ -179,6 +228,7 @@ pub extern "C" fn js_object_get_field_by_name(
keys as usize,
key as usize,
) {
prime_read_stub(o, key, idx);
return if (idx as usize) < alloc_limit {
super::accessors::js_object_get_field(o, idx)
} else {
Expand Down Expand Up @@ -214,6 +264,7 @@ pub extern "C" fn js_object_get_field_by_name(
key,
) {
let i = i as usize;
prime_read_stub(o, key, i as u32);
super::super::prop_plan::read_plan_record(
keys as usize,
key as usize,
Expand Down Expand Up @@ -1698,3 +1749,19 @@ mod null_key_guard_5972 {
}
}
}

/// Record `(shape token, key content) -> slot` for the megamorphic read stub.
///
/// Only called from inside the fast lane, i.e. once the receiver has already
/// been proved an ordinary shaped heap object with a resolvable own slot, so
/// the stub never learns an entry for a receiver whose reads have other
/// semantics. Keys that cannot be represented as content bits are skipped by
/// `read_stub_key_bits`.
#[inline]
fn prime_read_stub(obj: *const ObjectHeader, key: *const crate::StringHeader, slot: u32) {
if let Some(key_bits) = super::super::read_stub::read_stub_key_bits(key) {
if let Some(token) = unsafe { super::super::read_stub::receiver_shape_token(obj) } {
super::super::read_stub::read_stub_insert(token, key_bits, slot);
}
}
}
Loading
Loading