From ff9cb0788b9aa09a76f1f313ed8665c7b288927a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 25 Sep 2026 23:37:05 +0000 Subject: [PATCH 1/2] perf(runtime): property attributes live with the keys A key list that carries any non-default attribute owns a parallel attributes array (one entry per key position, with cumulative summary and Bloom words), attached through the named-properties reserve slot the collector already traces and growth already carries. Attribute-free key lists are unchanged. The canonical trie's edge is (key, entry): a default entry hashes as before, a key that arrives with its attributes appends in place, and a change to an existing key rebuilds the list from that key. The shape record carries the attribute summary byte (identity), which the class-chain store check reads first. An ordinary object's attributes leave the property_descriptors table, its owner index and its meta Bloom bits; the hashed attribute generation is deleted. Deletes, squeezes and the dictionary latch carry entries; a dictionary receiver edits its private list in place. The read IC declines only an accessor key and the write ICs only a key that is not plain writable data (emitted write PIC mask 0x1987 -> 0x1180). Builtin installs, fast-arm accessor defines and arguments objects claim their keys with their attributes. Default-off PERRY_ATTR_DIAG census behind the attr-census feature. --- changelog.d/attrs-with-keys.md | 19 + .../perry-codegen/src/expr/proxy_reflect.rs | 10 +- crates/perry-runtime/Cargo.toml | 3 + crates/perry-runtime/src/array/alloc.rs | 34 + crates/perry-runtime/src/array/mod.rs | 11 +- crates/perry-runtime/src/array/named_props.rs | 15 +- crates/perry-runtime/src/async_hooks.rs | 5 +- .../src/gc/tests/dead_owner_side_tables.rs | 12 +- .../perry-runtime/src/gc/tests/keys_attrs.rs | 78 ++ crates/perry-runtime/src/gc/tests/mod.rs | 1 + .../src/gc/tests/young_log_tests.rs | 10 +- .../json/stringify_shape_template_tests.rs | 8 +- crates/perry-runtime/src/object/arguments.rs | 120 +- crates/perry-runtime/src/object/assert.rs | 7 +- .../perry-runtime/src/object/attr_census.rs | 199 +++ .../src/object/canonical_keys.rs | 244 +++- .../object/canonical_keys_backing_tests.rs | 1 + .../src/object/canonical_keys_tests.rs | 12 +- crates/perry-runtime/src/object/cell_meta.rs | 73 ++ .../src/object/class_registry/state.rs | 5 +- .../perry-runtime/src/object/delete_rest.rs | 41 +- .../src/object/descriptor_state.rs | 447 ++++--- .../src/object/descriptor_state/filter.rs | 109 ++ .../src/object/descriptor_state/gc_scan.rs | 9 +- .../descriptor_state/owner_lifecycle.rs | 10 +- .../src/object/descriptor_state/tests.rs | 37 + crates/perry-runtime/src/object/dictionary.rs | 50 +- .../src/object/field_get_set/ic_miss.rs | 52 +- .../src/object/field_set_by_name/tail.rs | 5 + .../src/object/global_this/generator.rs | 3 +- .../src/object/global_this/install_static.rs | 35 +- .../src/object/global_this/math_temporal.rs | 35 +- .../src/object/global_this/populate.rs | 117 +- .../src/object/global_this/proto_methods.rs | 25 +- .../src/object/global_this/typed_array.rs | 12 +- .../src/object/global_this_webassembly.rs | 48 +- crates/perry-runtime/src/object/key_attrs.rs | 1065 +++++++++++++++++ .../src/object/key_attrs_tests.rs | 199 +++ crates/perry-runtime/src/object/mod.rs | 87 +- .../perry-runtime/src/object/native_module.rs | 7 +- .../object/native_module/callable_exports.rs | 56 +- crates/perry-runtime/src/object/object_ops.rs | 5 +- .../object/object_ops/define_get_accessor.rs | 22 +- .../src/object/object_ops/keys_array.rs | 79 +- .../src/object/reserved_floor.rs | 9 +- crates/perry-runtime/src/object/shapes.rs | 228 ++-- .../src/object/shapes_slot_list.rs | 21 +- .../perry-runtime/src/object/shapes_store.rs | 38 +- .../perry-runtime/src/object/shapes_tests.rs | 8 +- .../src/object/string_wrapper.rs | 5 +- .../src/object/temporal_proto.rs | 7 +- .../src/object/websocket_global.rs | 3 +- .../src/perf_hooks/prototypes.rs | 14 +- .../perry-runtime/src/promise/then_probe.rs | 7 +- crates/perry-runtime/src/proxy.rs | 6 +- crates/perry-runtime/src/proxy/put_value.rs | 20 +- .../src/proxy/put_value/packed_set.rs | 49 +- .../src/proxy/put_value/packed_set_tests.rs | 20 +- .../perry-runtime/src/timer/handle_object.rs | 9 +- crates/perry-runtime/src/web_storage.rs | 29 +- scripts/raw_handle_debt_baseline.txt | 2 +- scripts/raw_handle_debt_files.txt | 4 +- test-files/test_gap_attrs_in_shape.ts | 368 ++++++ test-files/test_gap_attrs_in_shape_sloppy.cts | 44 + test-files/test_gap_attrs_with_keys.ts | 263 ++++ 65 files changed, 3822 insertions(+), 754 deletions(-) create mode 100644 changelog.d/attrs-with-keys.md create mode 100644 crates/perry-runtime/src/gc/tests/keys_attrs.rs create mode 100644 crates/perry-runtime/src/object/attr_census.rs create mode 100644 crates/perry-runtime/src/object/descriptor_state/filter.rs create mode 100644 crates/perry-runtime/src/object/descriptor_state/tests.rs create mode 100644 crates/perry-runtime/src/object/key_attrs.rs create mode 100644 crates/perry-runtime/src/object/key_attrs_tests.rs create mode 100644 test-files/test_gap_attrs_in_shape.ts create mode 100644 test-files/test_gap_attrs_in_shape_sloppy.cts create mode 100644 test-files/test_gap_attrs_with_keys.ts diff --git a/changelog.d/attrs-with-keys.md b/changelog.d/attrs-with-keys.md new file mode 100644 index 0000000000..92dacbfbb3 --- /dev/null +++ b/changelog.d/attrs-with-keys.md @@ -0,0 +1,19 @@ +Property attributes live with the keys (charter step 3, V8's descriptor +arrays). A key list that carries any non-default attribute (writable / +enumerable / configurable, accessor-ness) owns a parallel attributes array, +attached through the named-properties reserve slot the collector already +traces; attribute-free key lists are unchanged. The canonical key trie's edge +is `(key, attributes)`, so equal keys with equal attributes share one list and +one ShapeId whatever the path, and a key that arrives with its attributes +(an `exports` getter per re-export, a builtin prototype method, an arguments +object's `length`/`callee`) is appended in place. Each shape record carries an +attribute summary byte that the prototype-chain store check reads first. + +An ordinary object's attributes no longer live in the address-keyed +`property_descriptors` table, its owner index or its meta-record Bloom bits, +and the attribute generation hash is gone. The read inline cache declines +only an accessor KEY and the write caches only a key that is not plain +writable data, so one `Object.defineProperty` no longer takes an object's +other keys off the property caches (#10871). The default-off +`PERRY_ATTR_DIAG` census (`--features perry-runtime/attr-census`) counts +which attribute paths a program runs. diff --git a/crates/perry-codegen/src/expr/proxy_reflect.rs b/crates/perry-codegen/src/expr/proxy_reflect.rs index 30f66a4178..b0d41c5435 100644 --- a/crates/perry-codegen/src/expr/proxy_reflect.rs +++ b/crates/perry-codegen/src/expr/proxy_reflect.rs @@ -56,7 +56,13 @@ use proxy_reflect_write_ic::StableTombstoneSlotCheck; // unconditional layout note retires the proof even for pointer-free tagged // values such as SSO strings and booleans. After that miss, the PIC is eligible // again. This keeps proof retirement out of every ordinary-object hit. -const WRITE_PIC_BLOCKING_FLAGS: u16 = 0x1987; +// +// Charter step 3 (#10871): FROZEN/SEALED/NO_EXTEND (0x7) and HAS_DESCRIPTORS +// (0x800) are NOT here. The prime that publishes this cache's tokens checks, +// per KEY, that the receiver's keys record the key as a plain writable data +// property (`key_attrs::entry_is_plain_writable_data`), and every attribute or +// integrity change moves the ShapeId, so the token compare proves it. +const WRITE_PIC_BLOCKING_FLAGS: u16 = 0x1180; /// #8098: `GcHeader::_reserved` bit 9 — the runtime birth-marked this /// class-less receiver an ORDINARY plain object (`JSON.parse` output), so it is @@ -457,7 +463,7 @@ fn guarded_declared_class_property_candidate(ctx: &FnCtx<'_>, target: &Expr) -> /// Registers arrive in k → v → t evaluation order (see the call site); from /// the target register onward the path is call-free until the store or the /// outlined slow call. Guards are byte-for-byte the static write PIC's -/// (GcHeader -8/-7/-6 with BLOCKING 0x1987 incl. typed-intact and the packed +/// (GcHeader -8/-7/-6 with BLOCKING 0x1180 incl. typed-intact and the packed /// numeric-proof authority, ObjectHeader /// regular/class/token via the #6804 discriminated shape-token select). /// The raw store fires only for non-reference VALUE tags (not pointer/ diff --git a/crates/perry-runtime/Cargo.toml b/crates/perry-runtime/Cargo.toml index 5be9bced35..41809cca48 100644 --- a/crates/perry-runtime/Cargo.toml +++ b/crates/perry-runtime/Cargo.toml @@ -36,6 +36,9 @@ crate-type = ["rlib"] # they look gets this backwards, so every call site is gated rather than # argued about. shape-mint-diag = [] +# Charter step 3: default-off `PERRY_ATTR_DIAG` census of the attribute +# machinery (where attributes are written, read, and which key-list paths run). +attr-census = [] # `default` keeps the shipped prebuilt libperry_runtime.a and plain # `cargo build`/test full-featured. The auto-optimize path builds with diff --git a/crates/perry-runtime/src/array/alloc.rs b/crates/perry-runtime/src/array/alloc.rs index c779f487af..8a38969cab 100644 --- a/crates/perry-runtime/src/array/alloc.rs +++ b/crates/perry-runtime/src/array/alloc.rs @@ -185,6 +185,40 @@ pub(crate) fn js_array_alloc_key_list(capacity: u32, all_ptr: bool) -> *mut Arra ptr } +/// [`js_array_alloc_key_list`] for a key list that carries ATTRIBUTES +/// (`object/key_attrs.rs`): one physical slot in front of logical element 0 +/// is reserved for the attributes pointer, under `GC_ARRAY_NAMED_PROPS`, so +/// `array_front_offset` is 1 from birth and every element reader (which goes +/// through `array_elements_ptr`) sees the same logical layout as an +/// attribute-free list. The reserve word starts as `TAG_HOLE`, a non-pointer, +/// so a collection before the attributes are attached traces nothing there. +pub(crate) fn js_array_alloc_key_list_reserved(capacity: u32, all_ptr: bool) -> *mut ArrayHeader { + let capacity = array_capacity_or_throw(capacity); + let reserve = crate::object::key_attrs::KEYS_ATTRS_FRONT_SLOTS; + let ptr = arena_alloc_gc( + array_byte_size(capacity as usize + reserve), + 8, + crate::gc::GC_TYPE_ARRAY, + ) as *mut ArrayHeader; + unsafe { + (*ptr).length = 0; + (*ptr).capacity = capacity; + // GC_STORE_AUDIT(INIT): the reserve word of a just-allocated array + // nothing references yet; a non-pointer, so no edge and no barrier. + std::ptr::write(ptr.add(1) as *mut u64, crate::value::TAG_HOLE); + clear_array_numeric_layout(ptr); + if all_ptr { + crate::gc::layout_init_all_pointer_slots(ptr as *mut u8); + } else { + crate::gc::layout_init_pointer_free(ptr as *mut u8); + } + let header = crate::gc::header_from_trusted_user_ptr(ptr.cast()).cast_mut(); + (*header)._reserved |= crate::gc::GC_ARRAY_NAMED_PROPS; + debug_assert_eq!(crate::array::array_front_offset(ptr), reserve); + } + ptr +} + /// Create a new empty array (convenience alias for `js_array_alloc(0)`). /// Used by perry-ui audio code. #[no_mangle] diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index c8197d1825..8878aa60dc 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -89,8 +89,8 @@ mod tests; mod typed_array_receiver_tests; pub(crate) use self::alloc::{ - array_length_range_error, js_array_alloc_key_list, js_array_alloc_pointer_elements, - js_array_alloc_with_length_exact, + array_length_range_error, js_array_alloc_key_list, js_array_alloc_key_list_reserved, + js_array_alloc_pointer_elements, js_array_alloc_with_length_exact, }; pub use self::alloc::{ js_array_alloc, js_array_alloc_literal, js_array_alloc_with_length, @@ -307,9 +307,10 @@ pub(crate) use self::named_props::{ array_has_named_properties_resolved, array_has_sparse_index_properties_resolved, array_named_property_delete, array_named_property_delete_by_name, array_named_property_get, array_named_property_get_by_name, array_named_property_has, array_named_property_names, - array_named_property_set, array_named_props_reserve, carry_named_props_reserve, - prune_dead_full_array_named_property_owners, transfer_full_array_named_props_owner, - visit_array_named_props_slots, + array_named_property_set, array_named_props_reserve, array_named_props_slot, + carry_named_props_reserve, ensure_named_props_slot, + prune_dead_full_array_named_property_owners, store_named_props_word, + transfer_full_array_named_props_owner, visit_array_named_props_slots, }; // Sole caller is the regex-engine-gated `regex::perex_results`, so the helpers diff --git a/crates/perry-runtime/src/array/named_props.rs b/crates/perry-runtime/src/array/named_props.rs index 27d69663cb..320b8f4c30 100644 --- a/crates/perry-runtime/src/array/named_props.rs +++ b/crates/perry-runtime/src/array/named_props.rs @@ -481,8 +481,19 @@ unsafe fn lookup( /// `arr` must be a live, forwarding-resolved head with a reserve, `pairs` a /// live pairs array. Nothing may allocate between the write and the barrier. unsafe fn store_pairs_pointer(arr: *mut ArrayHeader, pairs: *mut ArrayHeader) { + store_named_props_word(arr, crate::value::js_nanbox_pointer(pairs as i64).to_bits()); +} + +/// Store `bits` into the reserve header word of a flagged head, barriered. +/// The pairs pointer's store, shared with the keys-array attributes pointer +/// (`object/key_attrs.rs`), which reuses this reserve on internal key lists. +/// +/// # Safety +/// `arr` must be a live, forwarding-resolved head carrying +/// `GC_ARRAY_NAMED_PROPS`. +#[inline] +pub(crate) unsafe fn store_named_props_word(arr: *mut ArrayHeader, bits: u64) { let slot = array_named_props_slot(arr); - let bits = crate::value::js_nanbox_pointer(pairs as i64).to_bits(); // GC_STORE_AUDIT(BARRIERED): the reserved-slot edge is recorded by the // slot barrier below; the collector enumerates this exact word as a fixed // child slot of the array. @@ -617,7 +628,7 @@ unsafe fn materialize_inline(arr: *mut ArrayHeader) -> *mut ArrayHeader { /// # Safety /// `arr` must be a live, forwarding-resolved `GC_TYPE_ARRAY` head that /// `resolved_flags` reported as a real array. May allocate (via `js_array_grow`). -unsafe fn ensure_named_props_slot(arr: *mut ArrayHeader) -> *mut ArrayHeader { +pub(crate) unsafe fn ensure_named_props_slot(arr: *mut ArrayHeader) -> *mut ArrayHeader { if array_named_props_flagged_resolved(arr) { return arr; } diff --git a/crates/perry-runtime/src/async_hooks.rs b/crates/perry-runtime/src/async_hooks.rs index f944f94775..eade902e59 100644 --- a/crates/perry-runtime/src/async_hooks.rs +++ b/crates/perry-runtime/src/async_hooks.rs @@ -1251,13 +1251,10 @@ pub extern "C" fn js_async_resource_subclass_init( let current_this = this_handle.get_nanbox_f64(); let current_raw = crate::value::js_nanbox_get_pointer(current_this) as *mut ObjectHeader; - crate::object::js_object_set_field_by_name( + crate::object::define_builtin_data_property( current_raw, method_key, method_handle.get_nanbox_f64(), - ); - crate::object::set_builtin_property_attrs( - current_raw as usize, name.to_string(), crate::object::PropertyAttrs::new(true, false, true), ); diff --git a/crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs b/crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs index 3e12c60e3b..c234f51f37 100644 --- a/crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs +++ b/crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs @@ -284,10 +284,12 @@ fn test_tenured_owner_descriptor_entries_survive_minor_gc() { let _guard = GcTestIsolationGuard::new(); let (obj, _) = unsafe { alloc_old_test_object(0) }; let addr = obj as usize; - crate::object::set_property_attrs( + // An accessor: an ordinary object's DATA attributes live with its keys + // (charter step 3); its accessor closures are still owner-keyed. + crate::object::set_accessor_descriptor( addr, "oldKey".to_string(), - crate::object::PropertyAttrs::new(false, true, true), + crate::object::AccessorDescriptor::default(), ); // MINOR traces never mark the old generation — an unmarked old-gen @@ -296,7 +298,11 @@ fn test_tenured_owner_descriptor_entries_survive_minor_gc() { let _ = gc_collect_minor(); assert!( - crate::object::get_property_attrs(addr, "oldKey").is_some(), + crate::state::state() + .descriptors + .accessor_descriptors + .borrow() + .contains_key(&(addr, "oldKey".to_string())), "an old-gen owner's descriptor entry must survive a minor GC — \ minor-trace deadness is not trustworthy for tenured objects" ); diff --git a/crates/perry-runtime/src/gc/tests/keys_attrs.rs b/crates/perry-runtime/src/gc/tests/keys_attrs.rs new file mode 100644 index 0000000000..41f555e784 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/keys_attrs.rs @@ -0,0 +1,78 @@ +//! Charter step 3: a keys array's attributes array hangs off its +//! named-properties reserve word (`object/key_attrs.rs`). The collector already +//! emits that word as a fixed child slot of every flagged array; this pins +//! that a real copying minor moves BOTH the keys backing and its attributes +//! array and leaves the entries readable through the moved keys. +//! +//! Fault injection this catches: a keys-list allocator that forgets +//! `GC_ARRAY_NAMED_PROPS` (the reserve word is then never visited, the +//! attributes array dies in the nursery, and the entries read back as +//! whatever reuses its storage) — `attributes_survive_a_moving_minor`. + +use super::super::*; +use super::support::{collect_minor_trace, CopyingNurseryTestGuard, GcTriggerThresholdTestGuard}; +use crate::object::canonical_keys::{extend_key_with_entry, CanonicalKeys, SharedLayout}; +use crate::object::key_attrs::{self, ENTRY_ACCESSOR, ENTRY_HAS_GET, ENTRY_NON_ENUMERABLE}; + +#[test] +fn attributes_survive_a_moving_minor() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::support::register_runtime_handle_root_scanner_for_tests(); + gc_register_mutable_root_scanner(crate::object::canonical_keys::scan_canonical_keys_roots_mut); + crate::object::canonical_keys::reset_for_test(); + let proof = SharedLayout::shape_cache_entry(); + let entries = [0u8, ENTRY_NON_ENUMERABLE, ENTRY_ACCESSOR | ENTRY_HAS_GET]; + let scope = RuntimeHandleScope::new(); + let list = scope.root_raw_mut_ptr::(std::ptr::null_mut()); + let mut len = 0u32; + for (i, &e) in entries.iter().enumerate() { + let name = format!("ka_moving_{i}"); + let k = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + // `extend_key_with_entry` roots its operands across its allocation. + let next = list.with_mut_ptr(|arr: *mut crate::ArrayHeader| unsafe { + extend_key_with_entry(&proof, CanonicalKeys::from_rooted(arr, len), k, e) + }); + list.set_raw_mut_ptr(next.as_ptr()); + len = next.len(); + } + let (before_keys, before_attrs) = list.with_mut_ptr(|keys: *mut crate::ArrayHeader| { + (keys as usize, unsafe { key_attrs::keys_attrs(keys) } + as usize) + }); + assert_ne!( + before_attrs, 0, + "premise: the list carries an attributes array" + ); + assert!( + crate::arena::pointer_in_nursery(before_attrs), + "premise: the attributes array is young, so a copying minor moves it" + ); + let trace = collect_minor_trace(GcTriggerKind::Direct); + assert!( + trace.copying_nursery.copied_objects > 0, + "premise: the minor copied" + ); + list.with_mut_ptr(|after_keys: *mut crate::ArrayHeader| { + let after_attrs = unsafe { key_attrs::keys_attrs(after_keys) } as usize; + assert_ne!( + after_keys as usize, before_keys, + "premise: the keys backing moved" + ); + assert_ne!( + after_attrs, before_attrs, + "the attributes array must move with it" + ); + for (i, &e) in entries.iter().enumerate() { + assert_eq!( + unsafe { key_attrs::keys_entry(after_keys, i as u32) }, + e, + "entry {i} after the move" + ); + } + assert_eq!( + unsafe { key_attrs::keys_summary(after_keys, 3) }, + key_attrs::SUMMARY_NON_ENUMERABLE | key_attrs::SUMMARY_ACCESSOR + ); + }); +} diff --git a/crates/perry-runtime/src/gc/tests/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index b6a01a359d..a8ef472677 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -53,6 +53,7 @@ mod inline_generation_gate_contract; mod inline_pointer_bearing_contract; mod json_parse_scalar; mod json_stringify_output; +mod keys_attrs; mod layout_inline_mask; mod layout_pointer_free_hazard; mod layout_residue_histogram; diff --git a/crates/perry-runtime/src/gc/tests/young_log_tests.rs b/crates/perry-runtime/src/gc/tests/young_log_tests.rs index 68a651a5f4..f2d93cbb61 100644 --- a/crates/perry-runtime/src/gc/tests/young_log_tests.rs +++ b/crates/perry-runtime/src/gc/tests/young_log_tests.rs @@ -317,8 +317,16 @@ fn old_descriptor_owners_are_skipped_by_a_minor() { let _ = gc_collect_minor(); + // The TABLE entry, read directly: the owner's attributes (which say that + // `g` is an accessor) live with its keys since charter step 3, and this + // harness registers only the descriptor scanner, not the shape table's. assert_eq!( - crate::object::get_accessor_descriptor(owner, "g").map(|acc| acc.get), + crate::state::state() + .descriptors + .accessor_descriptors + .borrow() + .get(&(owner, "g".to_string())) + .map(|acc| acc.get), Some(ptr_bits(getter)) ); let row = walk("object.descriptors"); diff --git a/crates/perry-runtime/src/json/stringify_shape_template_tests.rs b/crates/perry-runtime/src/json/stringify_shape_template_tests.rs index 851ff7996b..df81de8bba 100644 --- a/crates/perry-runtime/src/json/stringify_shape_template_tests.rs +++ b/crates/perry-runtime/src/json/stringify_shape_template_tests.rs @@ -8,15 +8,15 @@ fn shape_template_declines_element_descriptors_before_output() { let value = crate::json::test_json_parse_direct(source); let obj = value.as_pointer::() as *mut crate::ObjectHeader; let template = build_shape_prefix_template(value.bits()).unwrap(); - // The descriptor bit travels with this receiver, independently of - // the shared keys array. Marking it must invalidate raw-slot emission - // even when a template was already built for the same shape. + // Charter step 3: the attribute lives with the keys, so the receiver + // moves to a different key list; raw-slot emission from the template + // built for the old one must decline. crate::object::set_property_attrs( obj as usize, "id".into(), crate::object::PropertyAttrs::new(true, false, true), ); - assert_eq!( + assert_ne!( crate::object::object_keys(obj).arr(), template.keys_arr.get() ); diff --git a/crates/perry-runtime/src/object/arguments.rs b/crates/perry-runtime/src/object/arguments.rs index 1f845f543f..2adafcfc68 100644 --- a/crates/perry-runtime/src/object/arguments.rs +++ b/crates/perry-runtime/src/object/arguments.rs @@ -15,10 +15,14 @@ struct ArgumentsMeta { crate::perry_thread_local! { static ARGUMENTS_OBJECTS: RefCell> = RefCell::new(crate::fast_hash::new_ptr_hash_map()); - // Bounded, agent-local cache of immutable ordered keys. Values, descriptors, - // and mapped boxes still belong to each individual arguments object. - static ARGUMENTS_KEYS: RefCell<[*mut ArrayHeader; 65]> = - RefCell::new([std::ptr::null_mut(); 65]); + // Bounded, agent-local cache of immutable ordered keys, one per arity and + // `callee` kind (index `len` for a mapped/sloppy `callee`, `65 + len` for a + // restricted one). The attributes of `length` and `callee` are part of the + // list (charter step 3, `key_attrs.rs`), so an arguments object is born + // with its final layout. Values, accessor closures and mapped boxes still + // belong to each individual arguments object. + static ARGUMENTS_KEYS: RefCell<[*mut ArrayHeader; 130]> = + RefCell::new([std::ptr::null_mut(); 130]); } /// Latched by the one and only registry insert (`js_arguments_object_create`). @@ -182,42 +186,70 @@ pub(super) fn thrower_closure_value() -> f64 { crate::value::js_nanbox_pointer(closure as i64) } -/// Build the complete own-key layout once for common arities. Larger calls use -/// the same bulk construction without retaining an unbounded cache of keys. -fn arguments_keys(len: u32) -> *mut ArrayHeader { - if let Some(keys) = ARGUMENTS_KEYS.with(|cache| cache.borrow().get(len as usize).copied()) { - if !keys.is_null() { - return keys; +/// The attribute entry of a mapped/sloppy arguments object's `length` and +/// `callee`: `{ writable: true, enumerable: false, configurable: true }`. +const HIDDEN_DATA_ENTRY: u8 = super::key_attrs::ENTRY_NON_ENUMERABLE; + +/// The entry of a restricted `callee`: a getter/setter pair (the thrower), +/// `{ enumerable: false, configurable: false }`. +const RESTRICTED_CALLEE_ENTRY: u8 = super::key_attrs::ENTRY_ACCESSOR + | super::key_attrs::ENTRY_HAS_GET + | super::key_attrs::ENTRY_HAS_SET + | super::key_attrs::ENTRY_NON_WRITABLE + | super::key_attrs::ENTRY_NON_ENUMERABLE + | super::key_attrs::ENTRY_NON_CONFIGURABLE; + +/// Build the complete own-key layout — the indices, then `length` and +/// `callee` WITH their attributes — once per arity and callee kind for common +/// arities. It is a canonical list (`canonical_keys.rs`), so every arguments +/// object of one arity shares it and nothing is copied per call. Larger calls +/// find the same canonical list without retaining a cache slot. +fn arguments_keys(len: u32, restricted_callee: bool) -> *mut ArrayHeader { + let slot_index = len as usize + if restricted_callee { 65 } else { 0 }; + let cacheable = len <= 64; + if cacheable { + if let Some(keys) = ARGUMENTS_KEYS.with(|cache| cache.borrow().get(slot_index).copied()) { + if !keys.is_null() { + return keys; + } } } + let proof = super::canonical_keys::SharedLayout::shape_cache_entry(); let scope = crate::gc::RuntimeHandleScope::new(); - let keys = scope.root_raw_mut_ptr(crate::array::js_array_alloc(len.saturating_add(2))); - for i in 0..len { - let key = intern_key(&i.to_string()); - let array = keys.with_mut_ptr(|array| { - crate::array::js_array_push_f64(array, crate::value::js_nanbox_string(key as i64)) - }); - keys.set_raw_mut_ptr(array); - } - for name in ["length", "callee"] { + let list = scope.root_raw_mut_ptr::(std::ptr::null_mut()); + let mut count = 0u32; + let mut append = |name: &str, entry: u8| { let key = intern_key(name); - let array = keys.with_mut_ptr(|array| { - crate::array::js_array_push_f64(array, crate::value::js_nanbox_string(key as i64)) - }); - keys.set_raw_mut_ptr(array); - } - keys.with_mut_ptr(|keys| unsafe { - // Every receiver must copy before adding/deleting keys, including the - // first receiver: later calls can reuse the cached layout after it dies. - let header = crate::value::addr_class::try_read_tracked_gc_header(keys as usize) - .expect("arguments keys have a tracked array header"); - (*header.as_ptr()).gc_flags |= crate::gc::GC_FLAG_SHAPE_SHARED; - ARGUMENTS_KEYS.with(|cache| { - if let Some(slot) = cache.borrow_mut().get_mut(len as usize) { - // GC_STORE_AUDIT(ROOT): the arguments scanner traces and rewrites this slot. - crate::gc::runtime_store_root_raw_mut_ptr_slot(slot, keys); - } + // SAFETY: the parent is rooted in `list`; the key is live and + // `extend_key_with_entry` roots both across its allocation. + let next = list.with_mut_ptr(|arr: *mut ArrayHeader| unsafe { + let parent = super::canonical_keys::CanonicalKeys::from_rooted(arr, count); + super::canonical_keys::extend_key_with_entry(&proof, parent, key, entry) }); + list.set_raw_mut_ptr(next.as_ptr()); + count = next.len(); + }; + for i in 0..len { + append(&i.to_string(), 0); + } + append("length", HIDDEN_DATA_ENTRY); + append( + "callee", + if restricted_callee { + RESTRICTED_CALLEE_ENTRY + } else { + HIDDEN_DATA_ENTRY + }, + ); + list.with_mut_ptr(|keys: *mut ArrayHeader| { + if cacheable { + ARGUMENTS_KEYS.with(|cache| { + if let Some(slot) = cache.borrow_mut().get_mut(slot_index) { + // GC_STORE_AUDIT(ROOT): the arguments scanner traces and rewrites this slot. + unsafe { crate::gc::runtime_store_root_raw_mut_ptr_slot(slot, keys) }; + } + }); + } keys }) } @@ -240,14 +272,15 @@ pub extern "C" fn js_arguments_object_alloc( crate::array::js_array_length(arr_ptr) }; - let keys = scope.root_raw_mut_ptr(arguments_keys(len)); + let keys = scope.root_raw_mut_ptr(arguments_keys(len, restricted_callee != 0)); let obj = scope.root_raw_mut_ptr(js_object_alloc(0, len.saturating_add(2))); obj.with_mut_ptr(|obj| { keys.with_mut_ptr(|keys| unsafe { - // The cached per-length list is exact and never grows. + // The cached list is canonical: its own count is `len + 2`, and a + // longer list may share its backing, so the count is stated. set_object_keys_with_live( obj, - crate::object::ObjectKeys::owned(keys), + crate::object::ObjectKeys::new(keys, len.saturating_add(2)), len.saturating_add(2), ); }); @@ -294,13 +327,10 @@ pub extern "C" fn js_arguments_object_alloc( len + 1, JSValue::from_bits(callee.get_nanbox_f64().to_bits()), ); - super::descriptor_state::set_property_attrs_batch( - obj as usize, - &[ - ("length", PropertyAttrs::new(true, false, true)), - ("callee", PropertyAttrs::new(true, false, true)), - ], - ); + // `length` and `callee` were born non-enumerable: their attributes + // are part of the cached layout. What remains of an install is the + // receiver-level bookkeeping every descriptor install performs. + super::descriptor_state::note_attrs_born_with_keys(obj as usize); }); } diff --git a/crates/perry-runtime/src/object/assert.rs b/crates/perry-runtime/src/object/assert.rs index 29fcd1862a..65602cd86e 100644 --- a/crates/perry-runtime/src/object/assert.rs +++ b/crates/perry-runtime/src/object/assert.rs @@ -1270,9 +1270,10 @@ pub extern "C" fn js_assert_assert_ctor(options: f64) -> f64 { b"constructor".as_ptr(), "constructor".len() as u32, ); - js_object_set_field_by_name(obj, key, ctor); - super::set_builtin_property_attrs( - obj as usize, + super::define_builtin_data_property( + obj, + key, + ctor, "constructor".to_string(), super::PropertyAttrs::new(true, false, true), ); diff --git a/crates/perry-runtime/src/object/attr_census.rs b/crates/perry-runtime/src/object/attr_census.rs new file mode 100644 index 0000000000..f5dc5a925f --- /dev/null +++ b/crates/perry-runtime/src/object/attr_census.rs @@ -0,0 +1,199 @@ +//! Charter step 3: a real-code census of the property-ATTRIBUTE machinery. +//! +//! Armed by `PERRY_ATTR_DIAG=`, compiled in only with the +//! `attr-census` feature (every hook is `#[cfg(feature = "attr-census")]`, so a +//! stock build carries none of it). One relaxed load when unarmed. +//! +//! What it answers, for tsc and Zod: which attribute side tables and +//! per-object flags a real program WRITES (installs, by API and by the cell +//! kind of the owner) and which accesses CONSULT them (by API, by call site, +//! by outcome). A table that real code never consults is a deletion with no +//! speed consequence; one consulted on a hot path is where a shape fact pays. +//! +//! Every event is keyed by `(event name, caller file, caller line)`; the call +//! site comes from `#[track_caller]` on the instrumented API functions, which +//! the same feature gates. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::{Mutex, OnceLock}; + +use crate::hot_diag::{sink_from_env, write_sink, Sink}; + +static STATE: AtomicU8 = AtomicU8::new(0); +static SINK: OnceLock> = OnceLock::new(); + +type Key = (&'static str, &'static str, u32); + +static TABLE: OnceLock>> = OnceLock::new(); +static KINDS: OnceLock>> = OnceLock::new(); + +fn table() -> &'static Mutex> { + crate::once_init::get_or_init(&TABLE, || Mutex::new(HashMap::new())) +} + +fn kinds() -> &'static Mutex> { + crate::once_init::get_or_init(&KINDS, || Mutex::new(HashMap::new())) +} + +/// [`note`] plus a split of the same event by the owner's cell kind. +#[track_caller] +pub(crate) fn note_kind(event: &'static str, owner: usize) { + if !armed() { + return; + } + note_at(event, std::panic::Location::caller()); + let kind = owner_kind(owner); + if let Ok(mut t) = kinds().lock() { + *t.entry((event, kind)).or_insert(0) += 1; + } +} + +fn sink() -> &'static Option { + crate::once_init::get_or_init(&SINK, || sink_from_env("PERRY_ATTR_DIAG")) +} + +#[inline] +pub(crate) fn armed() -> bool { + match STATE.load(Ordering::Relaxed) { + 1 => false, + 2 => true, + _ => resolve_arming(), + } +} + +#[cold] +#[inline(never)] +fn resolve_arming() -> bool { + let on = sink().is_some(); + STATE.store(if on { 2 } else { 1 }, Ordering::Relaxed); + if on { + register_exit_dump(); + } + on +} + +extern "C" fn exit_dump_shim() { + dump(); +} + +fn register_exit_dump() { + static REGISTERED: AtomicBool = AtomicBool::new(false); + if REGISTERED.swap(true, Ordering::Relaxed) { + return; + } + unsafe { + libc::atexit(exit_dump_shim); + } +} + +/// The cell kind of an owner, for install/consult splits. Reads only the GC +/// header; `"nonheap"` for anything without one (handle ids, typed arrays). +pub(crate) fn owner_kind(addr: usize) -> &'static str { + unsafe { + let Some(h) = crate::value::addr_class::try_read_gc_header(addr) else { + return "nonheap"; + }; + match h.obj_type { + // Only the header byte: asking `object_prototype_addr_matches` + // here can lazily build Object.prototype, whose builtin installs + // re-enter this census and recurse until the stack overflows. + crate::gc::GC_TYPE_OBJECT => "object", + crate::gc::GC_TYPE_ARRAY => "array", + crate::gc::GC_TYPE_CLOSURE => "closure", + crate::gc::GC_TYPE_ERROR => "error", + crate::gc::GC_TYPE_REGEXP => "regexp", + _ => "other", + } + } +} + +/// Record one event at an explicit location. +pub(crate) fn note_at(event: &'static str, loc: &'static std::panic::Location<'static>) { + if !armed() { + return; + } + let key = (event, loc.file(), loc.line()); + // A periodic snapshot every 2^20 events at one site, for a run that is + // killed before its exit hook. + let snapshot = match table().lock() { + Ok(mut t) => { + let n = t.entry(key).or_insert(0); + *n += 1; + *n & ((1 << 20) - 1) == 0 + } + Err(_) => false, + }; + if snapshot { + dump(); + } +} + +/// Record one event at the caller of the `#[track_caller]` function that +/// calls this. +#[track_caller] +#[inline] +pub(crate) fn note(event: &'static str) { + if !armed() { + return; + } + note_at(event, std::panic::Location::caller()); +} + +/// Add `n` to an event with no meaningful site (a byte count). +pub(crate) fn note_global_n(event: &'static str, n: u64) { + if !armed() { + return; + } + let key = (event, "-", 0); + if let Ok(mut t) = table().lock() { + *t.entry(key).or_insert(0) += n; + } +} + +/// Record an event with no meaningful site (`"-"`, line 0). +pub(crate) fn note_global(event: &'static str) { + if !armed() { + return; + } + let key = (event, "-", 0); + if let Ok(mut t) = table().lock() { + *t.entry(key).or_insert(0) += 1; + } +} + +fn dump() { + let Some(sink) = sink().as_ref() else { + return; + }; + let Ok(t) = table().lock() else { + return; + }; + let mut by_event: HashMap<&'static str, u64> = HashMap::new(); + for ((event, _, _), n) in t.iter() { + *by_event.entry(*event).or_insert(0) += *n; + } + let mut events: Vec<_> = by_event.into_iter().collect(); + events.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0))); + let mut out = String::new(); + out.push_str("# perry attr census (charter step 3)\n## totals by event\n"); + for (event, n) in &events { + out.push_str(&format!("{n:>12} {event}\n")); + } + if let Ok(k) = kinds().lock() { + out.push_str("## by event and owner kind\n"); + let mut rows: Vec<_> = k.iter().collect(); + rows.sort_by(|a, b| a.0 .0.cmp(b.0 .0).then(b.1.cmp(a.1))); + for ((event, kind), n) in rows { + out.push_str(&format!("{n:>12} {event} [{kind}]\n")); + } + } + out.push_str("## by event and call site\n"); + let mut rows: Vec<_> = t.iter().collect(); + rows.sort_by(|a, b| a.0 .0.cmp(b.0 .0).then(b.1.cmp(a.1))); + for ((event, file, line), n) in rows { + out.push_str(&format!("{n:>12} {event} {file}:{line}\n")); + } + out.push_str("ATTR_CENSUS_END\n"); + write_sink(sink, &out); +} diff --git a/crates/perry-runtime/src/object/canonical_keys.rs b/crates/perry-runtime/src/object/canonical_keys.rs index ede1027f1e..c3f8a6c164 100644 --- a/crates/perry-runtime/src/object/canonical_keys.rs +++ b/crates/perry-runtime/src/object/canonical_keys.rs @@ -118,6 +118,13 @@ impl CanonicalKeys { CanonicalKeys { arr, count } } + /// A handle rebuilt from a list this module returned, which the caller + /// held rooted (by its array) across an allocation: the handle's array + /// may have moved, its content and count cannot have changed. + pub(crate) fn from_rooted(arr: *mut ArrayHeader, count: u32) -> Self { + CanonicalKeys { arr, count } + } + /// The backing array. #[inline] pub(crate) fn as_ptr(self) -> *mut ArrayHeader { @@ -580,9 +587,24 @@ pub(crate) enum Appended { } impl Appended { + /// The edge hash of this slot appended with attribute `entry`. A default + /// entry (0) hashes exactly as the slot alone did before attributes lived + /// with the keys, so every attribute-free layout keeps its edges. + /// + /// # Safety + /// The operand is live. + unsafe fn edge_hash(self, entry: u8) -> u64 { + let h = self.slot_hash(); + if entry == 0 { + h + } else { + (h ^ u64::from(entry).wrapping_mul(0x9E37_79B9_7F4A_7C15)).rotate_left(29) + } + } + /// # Safety /// The operand is live. - unsafe fn edge_hash(self) -> u64 { + unsafe fn slot_hash(self) -> u64 { match self { Appended::Key(key) => { if key.is_null() { @@ -684,6 +706,7 @@ unsafe fn probe_node( pnode: u32, parent_len: u32, appended: Appended, + entry: u8, h: u64, ) -> Option { let mut cur = *t.edges.get(&(pnode, h))?; @@ -699,7 +722,11 @@ unsafe fn probe_node( #[cfg(test)] canonical_keys_tests::note_slot_read(); let stored = JSValue::from_bits((*slots.add(parent_len as usize)).to_bits()); - if appended.matches(stored) { + // The attribute entry is half of the edge: the same key + // with other attributes is a different list. + if appended.matches(stored) + && crate::object::key_attrs::keys_entry(arr, parent_len) == entry + { return Some(cur); } } @@ -714,10 +741,11 @@ unsafe fn probe( parent: CanonicalKeys, parent_len: u32, appended: Appended, + entry: u8, h: u64, ) -> Option { with_table_or(None, |t| { - let id = probe_node(t, node_of(t, parent)?, parent_len, appended, h)?; + let id = probe_node(t, node_of(t, parent)?, parent_len, appended, entry, h)?; let node = &t.nodes[id as usize]; let hit = node .published @@ -742,7 +770,8 @@ unsafe fn stamp_shared(arr: *mut ArrayHeader) { } /// The canonical array for `parent`'s ordered key list with one slot -/// appended. A hit is O(1): one hash probe and one exact slot check. A new +/// appended, carrying attribute `entry` (`key_attrs.rs`; 0 = default). A hit +/// is O(1): one hash probe and one exact slot-and-entry check. A new /// publication owns a copy; no intermediate unpublished prefix allocates. /// /// # Safety @@ -753,10 +782,11 @@ pub(crate) unsafe fn extend_slot( _proof: &SharedLayout, parent: CanonicalKeys, appended: Appended, + entry: u8, ) -> CanonicalKeys { - let h = appended.edge_hash(); + let h = appended.edge_hash(entry); let parent_len = parent.len(); - if let Some(hit) = probe(parent, parent_len, appended, h) { + if let Some(hit) = probe(parent, parent_len, appended, entry, h) { return hit; } @@ -773,9 +803,13 @@ pub(crate) unsafe fn extend_slot( let all_ptr = parent_all_ptr && appended.is_pointer(); // The tip of its backing grows in place: no copy, no new array. - if let Some(child) = append_at_tip(parent, appended, h, parent_all_ptr, all_ptr) { + if let Some(child) = append_at_tip(parent, appended, entry, h, parent_all_ptr, all_ptr) { return child; } + // The child carries an attributes array iff some entry of it is not the + // default: the parent's summary is exact for a canonical prefix. + let with_attrs = entry != 0 + || crate::object::key_attrs::keys_summary(parent.as_const_ptr(), parent_len) != 0; // Nothing may be held across the allocation: no table borrow (a collection // re-enters this table through its scanner and its prune) and both @@ -792,7 +826,7 @@ pub(crate) unsafe fn extend_slot( let capacity = backing_capacity(parent_len + 1); #[cfg(test)] try_with_table(|t| t.allocated_slots += u64::from(capacity)); - let allocate = || crate::array::js_array_alloc_key_list(capacity, all_ptr); + let allocate = || crate::object::key_attrs::alloc_key_list(capacity, all_ptr, with_attrs); // Reload both operands only after the child allocation can no longer // move them. No GC allocation occurs while the fresh array is filled. let ((fresh, parent), appended) = match appended { @@ -812,7 +846,7 @@ pub(crate) unsafe fn extend_slot( // A collection during the allocation may have published this exact node // through another path, or pruned the parent. Re-probe before writing. - if let Some(hit) = probe(parent, parent_len, appended, h) { + if let Some(hit) = probe(parent, parent_len, appended, entry, h) { return hit; } @@ -837,6 +871,24 @@ pub(crate) unsafe fn extend_slot( } } *dst.add(parent_len as usize) = appended.element_word(); + if with_attrs { + #[cfg(feature = "attr-census")] + crate::object::attr_census::note_global(if entry != 0 { + "keys.fork_with_attrs.entry" + } else { + "keys.fork_with_attrs.default" + }); + // The parent's entries, then this one: an attribute list's prefix is + // copied with its keys, exactly like the keys themselves. + let attrs = crate::object::key_attrs::keys_attrs(fresh); + crate::object::key_attrs::copy_entries(parent.as_const_ptr(), 0, attrs, parent_len); + crate::object::key_attrs::attrs_write( + attrs, + parent_len, + entry, + JSValue::from_bits(appended.element_word().to_bits()), + ); + } (*fresh).length = parent_len + 1; if !all_ptr { // Only the mixed list needs the slot walk; the all-pointer allocator @@ -857,7 +909,7 @@ pub(crate) unsafe fn extend_slot( // it is a correct list — as an orphan root: the caller still gets the // right content and only the edge is lost. let pnode = node_of(t, parent).unwrap_or(NO_NODE); - let id = if let Some(id) = probe_node(t, pnode, parent_len, appended, h) { + let id = if let Some(id) = probe_node(t, pnode, parent_len, appended, entry, h) { t.publish(id, fresh as usize, all_ptr); id } else { @@ -898,6 +950,7 @@ fn backing_capacity(len: u32) -> u32 { unsafe fn append_at_tip( parent: CanonicalKeys, appended: Appended, + entry: u8, h: u64, parent_all_ptr: bool, all_ptr: bool, @@ -915,6 +968,16 @@ unsafe fn append_at_tip( if parent_all_ptr && !all_ptr { return None; } + // A backing without attributes cannot take a non-default entry in place: + // its front has no reserve. That forks ONCE, into a backing that carries + // an attributes array, and the chain's later appends land here again. + let attrs = crate::object::key_attrs::keys_attrs(backing); + if entry != 0 && attrs.is_null() { + return None; + } + if !attrs.is_null() && ((*attrs).length != parent_len || parent_len >= (*attrs).capacity) { + return None; + } let pnode = with_table_or(None, |t| node_of(t, parent))?; debug_assert!( crate::value::addr_class::try_read_tracked_gc_header(backing as usize) @@ -922,6 +985,22 @@ unsafe fn append_at_tip( "a canonical backing is shape-shared from birth" ); CANON_IN_PLACE_APPENDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if !attrs.is_null() { + // Past every published count, like the key slot: no list sharing the + // backing sees it until the length below covers it. + crate::object::key_attrs::attrs_write( + attrs, + parent_len, + entry, + JSValue::from_bits(appended.element_word().to_bits()), + ); + #[cfg(feature = "attr-census")] + crate::object::attr_census::note_global(if entry != 0 { + "keys.tip_append.entry" + } else { + "keys.tip_append.default_on_attr_backing" + }); + } // The array store helper writes the slot, notes its layout (a mixed // backing keeps its per-slot mask) and runs the write barrier (an old // backing can take a young key). @@ -932,7 +1011,7 @@ unsafe fn append_at_tip( ); (*backing).length = parent_len + 1; try_with_table(|t| { - let id = if let Some(id) = probe_node(t, pnode, parent_len, appended, h) { + let id = if let Some(id) = probe_node(t, pnode, parent_len, appended, entry, h) { t.publish(id, backing as usize, all_ptr); id } else { @@ -953,7 +1032,82 @@ pub(crate) unsafe fn extend_key( parent: CanonicalKeys, key: *const StringHeader, ) -> CanonicalKeys { - extend_slot(proof, parent, Appended::Key(key)) + extend_slot(proof, parent, Appended::Key(key), 0) +} + +/// [`extend_key`] for a key that arrives WITH its attributes (an accessor +/// install, a literal `get`/`set`, `defineProperty` of a new key): one edge, +/// and on the tip of an attribute backing an in-place append. +/// +/// # Safety +/// As [`extend_slot`]. +#[inline] +pub(crate) unsafe fn extend_key_with_entry( + proof: &SharedLayout, + parent: CanonicalKeys, + key: *const StringHeader, + entry: u8, +) -> CanonicalKeys { + extend_slot(proof, parent, Appended::Key(key), entry) +} + +/// The canonical list for `keys` with the attribute entries of positions +/// `from..count` replaced by `entry_at(position, slot, current entry)` — how a +/// change to an EXISTING key's attributes (a `defineProperty` redefinition, +/// `freeze`, `seal`) is expressed. The prefix below `from` is shared as is; +/// the rest is re-appended one edge at a time, so after the first fork every +/// append lands on the new backing's tip: O(count - from), like V8 copying a +/// descriptor array. +/// +/// # Safety +/// `keys` is a live list with `count` initialized slots; the caller has +/// rooted what it holds: this allocates. +pub(crate) unsafe fn rebuild_with_entries( + proof: &SharedLayout, + keys: crate::object::ObjectKeys, + from: u32, + mut entry_at: impl FnMut(u32, JSValue, u8) -> u8, +) -> CanonicalKeys { + let count = keys.count(); + let from = from.min(count); + #[cfg(feature = "attr-census")] + { + crate::object::attr_census::note_global("keys.rebuild_with_entries"); + if from + 1 == count { + crate::object::attr_census::note_global("keys.rebuild_with_entries.last_key"); + } + } + let scope = crate::gc::RuntimeHandleScope::new(); + let src = scope.root_raw_mut_ptr(keys.arr()); + let prefix = src.with_const_ptr(|arr: *const ArrayHeader| canonicalize(proof, arr, from)); + let list = scope.root_raw_mut_ptr(prefix.as_ptr()); + let mut len = prefix.len(); + for pos in from..count { + let (slot, old) = src.with_const_ptr(|arr: *const ArrayHeader| { + let (slots, available) = crate::object::keys_array_dense_slots(arr); + assert!( + (pos as usize) < available, + "a shape's count outruns its keys" + ); + ( + JSValue::from_bits((*slots.add(pos as usize)).to_bits()), + crate::object::key_attrs::keys_entry(arr, pos), + ) + }); + let entry = entry_at(pos, slot, old); + // `extend_slot` roots its operands across its own allocation. + let next = list.with_mut_ptr(|arr: *mut ArrayHeader| { + extend_slot( + proof, + CanonicalKeys::new(arr, len), + Appended::Slot(slot), + entry, + ) + }); + list.set_raw_mut_ptr(next.as_ptr()); + len = next.len(); + } + list.with_mut_ptr(|arr: *mut ArrayHeader| CanonicalKeys::new(arr, len)) } /// The canonical array for the ordered key list held in `keys[0..len]`. @@ -992,7 +1146,8 @@ pub(crate) unsafe fn canonicalize( let mut parent = ROOT_NODE; for i in 0..len { let slot = Appended::Slot(JSValue::from_bits((*slots.add(i as usize)).to_bits())); - parent = probe_node(t, parent, i, slot, slot.edge_hash())?; + let entry = crate::object::key_attrs::keys_entry(keys, i); + parent = probe_node(t, parent, i, slot, entry, slot.edge_hash(entry))?; } let node = &t.nodes[parent as usize]; let hit = node @@ -1010,18 +1165,25 @@ pub(crate) unsafe fn canonicalize( let (slots, _) = crate::object::keys_array_dense_slots(keys); let all_ptr = (0..len).all(|i| JSValue::from_bits((*slots.add(i as usize)).to_bits()).is_string()); + // Exact, not the summary: an owned source edited in place over-reports. + let with_attrs = crate::object::key_attrs::keys_have_entries(keys, len); let scope = crate::gc::RuntimeHandleScope::new(); let src = scope.root_raw_const_ptr(keys); #[cfg(test)] try_with_table(|t| t.allocated_slots += u64::from(len)); - let (fresh, keys) = - src.across_const::(|| crate::array::js_array_alloc_key_list(len, all_ptr)); + let (fresh, keys) = src.across_const::(|| { + crate::object::key_attrs::alloc_key_list(len, all_ptr, with_attrs) + }); let (slots, available) = crate::object::keys_array_dense_slots(keys); assert!(available >= len as usize); let dst = crate::array::array_elements_ptr(fresh) as *mut f64; // GC_STORE_AUDIT(INIT): fresh is unpublished; publish length only after // all elements are initialized, with no intervening GC allocation. std::ptr::copy_nonoverlapping(slots, dst, len as usize); + if with_attrs { + let attrs = crate::object::key_attrs::keys_attrs(fresh); + crate::object::key_attrs::copy_entries(keys, 0, attrs, len); + } (*fresh).length = len; if !all_ptr { crate::object::gc_slots::rebuild_array_layout_from_slots(fresh); @@ -1040,9 +1202,10 @@ pub(crate) unsafe fn canonicalize( let mut prefix_all_ptr = true; for i in 0..len { let slot = Appended::Slot(JSValue::from_bits((*dst.add(i as usize)).to_bits())); - let h = slot.edge_hash(); + let entry = crate::object::key_attrs::keys_entry(fresh, i); + let h = slot.edge_hash(entry); prefix_all_ptr &= slot.is_pointer(); - parent = match probe_node(t, parent, i, slot, h) { + parent = match probe_node(t, parent, i, slot, entry, h) { Some(id) => id, None => t.alloc_node(fresh as usize, parent, h, i + 1, prefix_all_ptr, false), }; @@ -1058,6 +1221,53 @@ pub(crate) unsafe fn canonicalize( }) } +/// The canonical list for `keys` without position `remove`, every other +/// key's attribute entry carried: a delete from a shared list that carries +/// attributes. The prefix below `remove` is shared as is. +/// +/// # Safety +/// As [`rebuild_with_entries`]; `remove < keys.count()`. +pub(crate) unsafe fn rebuild_removing( + proof: &SharedLayout, + keys: crate::object::ObjectKeys, + remove: u32, +) -> CanonicalKeys { + let count = keys.count(); + debug_assert!(remove < count); + #[cfg(feature = "attr-census")] + crate::object::attr_census::note_global("keys.rebuild_removing"); + let scope = crate::gc::RuntimeHandleScope::new(); + let src = scope.root_raw_mut_ptr(keys.arr()); + let prefix = src.with_const_ptr(|arr: *const ArrayHeader| canonicalize(proof, arr, remove)); + let list = scope.root_raw_mut_ptr(prefix.as_ptr()); + let mut len = prefix.len(); + for pos in remove + 1..count { + let (slot, entry) = src.with_const_ptr(|arr: *const ArrayHeader| { + let (slots, available) = crate::object::keys_array_dense_slots(arr); + assert!( + (pos as usize) < available, + "a shape's count outruns its keys" + ); + ( + JSValue::from_bits((*slots.add(pos as usize)).to_bits()), + crate::object::key_attrs::keys_entry(arr, pos), + ) + }); + // `extend_slot` roots its operands across its own allocation. + let next = list.with_mut_ptr(|arr: *mut ArrayHeader| { + extend_slot( + proof, + CanonicalKeys::new(arr, len), + Appended::Slot(slot), + entry, + ) + }); + list.set_raw_mut_ptr(next.as_ptr()); + len = next.len(); + } + list.with_mut_ptr(|arr: *mut ArrayHeader| CanonicalKeys::new(arr, len)) +} + /// GC root scanner. The arrays are WEAK: rewritten on move, never marked — /// see the module note on #6759 phase 3. pub fn scan_canonical_keys_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { diff --git a/crates/perry-runtime/src/object/canonical_keys_backing_tests.rs b/crates/perry-runtime/src/object/canonical_keys_backing_tests.rs index bf3cd65d84..9fd833a599 100644 --- a/crates/perry-runtime/src/object/canonical_keys_backing_tests.rs +++ b/crates/perry-runtime/src/object/canonical_keys_backing_tests.rs @@ -138,6 +138,7 @@ fn a_fork_and_a_full_backing_start_a_new_backing() { &proof, fifth, Appended::Slot(JSValue::from_bits(crate::value::TAG_HOLE)), + 0, ); assert_ne!(hole.as_ptr(), fifth.as_ptr()); assert_eq!( diff --git a/crates/perry-runtime/src/object/canonical_keys_tests.rs b/crates/perry-runtime/src/object/canonical_keys_tests.rs index e941441152..0efd99e56e 100644 --- a/crates/perry-runtime/src/object/canonical_keys_tests.rs +++ b/crates/perry-runtime/src/object/canonical_keys_tests.rs @@ -161,28 +161,34 @@ fn a_tombstone_position_is_part_of_the_identity() { &p, extend_slot( &p, - extend_slot(&p, CanonicalKeys::EMPTY, Appended::Slot(hole)), + extend_slot(&p, CanonicalKeys::EMPTY, Appended::Slot(hole), 0), Appended::Key(a), + 0, ), Appended::Key(b), + 0, ); let hole_middle = extend_slot( &p, extend_slot( &p, - extend_slot(&p, CanonicalKeys::EMPTY, Appended::Key(a)), + extend_slot(&p, CanonicalKeys::EMPTY, Appended::Key(a), 0), Appended::Slot(hole), + 0, ), Appended::Key(b), + 0, ); let hole_first_again = extend_slot( &p, extend_slot( &p, - extend_slot(&p, CanonicalKeys::EMPTY, Appended::Slot(hole)), + extend_slot(&p, CanonicalKeys::EMPTY, Appended::Slot(hole), 0), Appended::Key(a), + 0, ), Appended::Key(b), + 0, ); assert_eq!( hole_first.addr(), diff --git a/crates/perry-runtime/src/object/cell_meta.rs b/crates/perry-runtime/src/object/cell_meta.rs index 4804602457..820153bac4 100644 --- a/crates/perry-runtime/src/object/cell_meta.rs +++ b/crates/perry-runtime/src/object/cell_meta.rs @@ -35,6 +35,19 @@ pub(crate) unsafe fn cell_meta_slot(user_ptr: usize) -> Option<*mut *mut ObjectM let Some(gc_hdr) = crate::value::addr_class::try_read_gc_header(user_ptr) else { return None; }; + cell_meta_slot_for_header(user_ptr, gc_hdr) +} + +/// [`cell_meta_slot`] for a caller that already holds `user_ptr`'s header +/// from `try_read_gc_header`. +/// +/// # Safety +/// `gc_hdr` is `try_read_gc_header(user_ptr)`'s answer, read in this scope. +#[inline] +pub(crate) unsafe fn cell_meta_slot_for_header( + user_ptr: usize, + gc_hdr: &crate::gc::GcHeader, +) -> Option<*mut *mut ObjectMeta> { match gc_hdr.obj_type { crate::gc::GC_TYPE_OBJECT => { Some(&mut (*(user_ptr as *mut ObjectHeader)).meta as *mut *mut ObjectMeta) @@ -69,3 +82,63 @@ pub(crate) unsafe fn cell_meta_slot(user_ptr: usize) -> Option<*mut *mut ObjectM pub(crate) unsafe fn cell_has_meta_edge(user_ptr: usize) -> bool { cell_meta_slot(user_ptr).is_some() } + +/// The named-property bag for a cell that has no inline slot layout of its own, +/// creating it on first write. +/// +/// #6759 phase 1. An `ErrorHeader` (and the other exotic cells) cannot hold +/// named properties inline, so they lived in tables keyed by the owner's +/// ADDRESS — `ERROR_USER_PROPS` and friends — which cost four GC hooks +/// (rekey-on-evacuation, finalize, dead-sweep, root scanner) and carried a +/// standing hazard: a recycled address inherits the previous tenant's +/// properties. +/// +/// The bag is an ordinary object hanging off `ObjectMeta.expando`, so it is an +/// ordinary child edge — it moves with its owner, dies with its owner, and +/// keeps ECMA-262 insertion order for free because that is what an object's +/// `keys_array` already does. +pub(crate) unsafe fn cell_expando_ensure(user_ptr: usize) -> Option<*mut ObjectHeader> { + let meta = object_meta_ensure_for_cell(user_ptr)?; + if (*meta).expando != 0 { + return Some( + crate::value::JSValue::from_bits((*meta).expando).as_pointer::() + as *mut ObjectHeader, + ); + } + // `js_object_alloc` allocates and can move the owner, so re-resolve the + // meta record from the rooted address afterwards. + let scope = crate::gc::RuntimeHandleScope::new(); + let owner = scope.root_raw_mut_ptr(user_ptr as *mut u8); + let bag = js_object_alloc(0, 0); + let user_ptr = owner.get_raw_mut_ptr::() as usize; + let meta = object_meta_ensure_for_cell(user_ptr)?; + if (*meta).expando != 0 { + return Some( + crate::value::JSValue::from_bits((*meta).expando).as_pointer::() + as *mut ObjectHeader, + ); + } + let boxed = crate::value::js_nanbox_pointer(bag as i64).to_bits(); + // GC_STORE_AUDIT(BARRIERED): metadata-record slot store + object barrier. + (*meta).expando = boxed; + crate::gc::runtime_write_barrier_slot( + meta as usize, + &(*meta).expando as *const _ as usize, + boxed, + ); + Some(bag) +} + +/// The existing bag, or `None` when the owner never took one. Never allocates, +/// so it is safe on read paths. +pub(crate) unsafe fn cell_expando_get(user_ptr: usize) -> Option<*mut ObjectHeader> { + let slot = cell_meta_slot(user_ptr)?; + let meta = *slot; + if meta.is_null() || (*meta).expando == 0 { + return None; + } + Some( + crate::value::JSValue::from_bits((*meta).expando).as_pointer::() + as *mut ObjectHeader, + ) +} diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index 5fab2ff7e7..2ccff45fb6 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -1099,13 +1099,10 @@ pub(crate) fn class_decl_prototype_value(class_id: u32) -> f64 { let constructor_key = crate::string::js_string_from_bytes(b"constructor".as_ptr(), "constructor".len() as u32); - js_object_set_field_by_name( + define_builtin_data_property( proto, constructor_key, class_constructor_ref_value(class_id), - ); - set_builtin_property_attrs( - proto as usize, "constructor".to_string(), PropertyAttrs::new(true, false, true), ); diff --git a/crates/perry-runtime/src/object/delete_rest.rs b/crates/perry-runtime/src/object/delete_rest.rs index 24e0da4dd7..76a55e1d65 100644 --- a/crates/perry-runtime/src/object/delete_rest.rs +++ b/crates/perry-runtime/src/object/delete_rest.rs @@ -414,7 +414,13 @@ pub extern "C" fn js_object_delete_field( // Keep this below 16 slots, matching the small-object threshold. Wide // populated receivers retain the existing clone+compact ownership // transfer and its index migration (#9064 is their separate lane). - if !keys_owned && key_count < 16 && object_tombstone_deletes_enabled() { + // A list that carries attributes (`key_attrs.rs`) is never forked into + // a private copy: an owned list with attributes belongs to a + // dictionary receiver only, and the canonical compaction below keeps + // this one shared. + let keys_carry_attrs = !crate::object::key_attrs::keys_attrs(keys).is_null(); + if !keys_owned && key_count < 16 && object_tombstone_deletes_enabled() && !keys_carry_attrs + { let scope = crate::gc::RuntimeHandleScope::new(); let obj_handle = scope.root_raw_mut_ptr(obj); let (keys_cloned, reloaded_obj) = obj_handle.across_mut::(|| { @@ -535,6 +541,8 @@ pub extern "C" fn js_object_delete_field( elements.add(i) as usize, crate::value::TAG_HOLE, ); + // A hole has no attributes. + crate::object::key_attrs::owned_note_hole(keys, i as u32); if i < alloc_limit { let fields_ptr = (obj as *mut u8).add(std::mem::size_of::()) as *mut u64; @@ -607,6 +615,9 @@ pub extern "C" fn js_object_delete_field( std::ptr::copy(elements.add(i + 1), elements.add(i), new_count - i); } (*keys).length = new_count as u32; + // The attributes shift with their keys (a dictionary receiver's + // private list is the one owned list that carries them). + crate::object::key_attrs::owned_note_remove(keys, i as u32); super::rebuild_array_layout_from_slots(keys); // Re-publish the shape for the SAME array at its new key count. // Without this the object keeps a stamped ShapeId whose descriptor @@ -618,6 +629,24 @@ pub extern "C" fn js_object_delete_field( // An owned (unshared) list: its header length is its count. set_object_keys(obj, crate::object::ObjectKeys::owned(keys)); super::shapes::shape_index_shift_in_place(keys as usize, i as u32, key_count as u32) + } else if keys_carry_attrs { + // A shared list with attributes: its successor is the canonical + // list without key `i`, attributes carried (`key_attrs.rs`), so + // no private copy ever holds attributes outside a dictionary. + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_handle = scope.root_raw_mut_ptr(obj); + let (successor, reloaded) = obj_handle.across_mut::(|| { + let proof = crate::object::canonical_keys::SharedLayout::of_receiver(obj) + .expect("a shared keys list belongs to a non-dictionary receiver"); + crate::object::canonical_keys::rebuild_removing( + &proof, + crate::object::object_keys(obj), + i as u32, + ) + }); + obj = reloaded; + set_object_keys(obj, successor.view()); + false } else { let keys_cloned = crate::array::js_array_alloc(new_count.max(1) as u32 + 4); let src_elements = @@ -1819,11 +1848,18 @@ unsafe fn squeeze_holes_and_delete( let fields_ptr = (obj as *mut u8).add(std::mem::size_of::()) as *mut u64; let floor = reserved_floor.min(key_count); let mut out = floor; + // Attributes move with their keys; only a list that carries them pays for + // the position map. + let mut kept: Option> = (!crate::object::key_attrs::keys_attrs(keys).is_null()) + .then(|| (0..floor as u32).collect()); for s in floor..key_count { let kv = std::ptr::read(elements.add(s)); if s == delete_slot || kv.to_bits() == crate::value::TAG_HOLE { continue; } + if let Some(kept) = kept.as_mut() { + kept.push(s as u32); + } if out != s { // Keys move DOWN within one buffer (out < s always) — same // overlap argument as `compact_map_entries`. @@ -1850,6 +1886,9 @@ unsafe fn squeeze_holes_and_delete( out += 1; } (*keys).length = out as u32; + if let Some(kept) = kept { + crate::object::key_attrs::owned_note_compaction(keys, &kept); + } if out > 0 { // GC_STORE_AUDIT(EXTERNAL_BARRIERED): dirty-span barrier over the // compacted key slots, mirroring compact_map_entries. diff --git a/crates/perry-runtime/src/object/descriptor_state.rs b/crates/perry-runtime/src/object/descriptor_state.rs index a7aa34fda0..9c755451c1 100644 --- a/crates/perry-runtime/src/object/descriptor_state.rs +++ b/crates/perry-runtime/src/object/descriptor_state.rs @@ -135,7 +135,12 @@ thread_local! { static TEST_SUPPRESS_DESCRIPTOR_YOUNG_NOTE: Cell = const { Cell::new(false) }; } +mod filter; mod gc_scan; +pub(crate) use filter::may_have_descriptor_entry; +#[cfg(test)] +pub(crate) use filter::test_may_have_descriptor_entry; +use filter::{descriptor_route, meta_may_have, DescriptorRoute}; mod owner_lifecycle; mod young; pub(crate) use gc_scan::{scan_descriptor_owner, scan_descriptor_roots_mut}; @@ -169,39 +174,7 @@ fn note_young_descriptor_owner( } #[cfg(test)] -mod young_log_sabotage_tests { - use super::*; - - #[test] - fn descriptor_log_rederivation_rejects_a_suppressed_setter() { - let _lock = crate::gc::global_side_table_test_lock(); - let owner = crate::object::js_object_alloc(0, 0) as usize; - state().descriptors.young_owners.borrow_mut().clear(); - TEST_SUPPRESS_DESCRIPTOR_YOUNG_NOTE.with(|flag| flag.set(true)); - set_property_attrs( - owner, - "sabotage".to_string(), - PropertyAttrs::new(true, true, true), - ); - TEST_SUPPRESS_DESCRIPTOR_YOUNG_NOTE.with(|flag| flag.set(false)); - let missed = std::panic::catch_unwind(|| { - state() - .descriptors - .young_owners - .borrow() - .debug_assert_logged( - DESCRIPTOR_YOUNG_LOG_NAME, - &relevant_descriptor_owners(state()), - ); - }); - clear_property_attrs(owner, "sabotage"); - state().descriptors.young_owners.borrow_mut().clear(); - assert!( - missed.is_err(), - "sabotage: suppressing set_property_attrs' note must trip completeness" - ); - } -} +mod tests; /// Record `key` as owned by `owner` in an owner index. Idempotent: a /// `defineProperty` that overwrites an existing descriptor must not push a @@ -562,7 +535,17 @@ pub(crate) unsafe fn class_instance_set_may_intercept( // carries descriptors (constructor / method install), which would defeat // the fast path entirely. Only an inherited accessor or non-writable data // property *named this key* actually intercepts the write. - if object_has_descriptors(p) { + // Charter step 3: an ordinary prototype's attributes live with its + // keys, and its shape's summary proves most hops (methods are + // non-enumerable, never non-writable) without a key lookup. + if super::key_attrs::attrs_live_in_keys(p) { + if super::key_attrs::object_key_blocks_plain_store( + p as *const ObjectHeader, + name.as_bytes(), + ) { + return true; + } + } else if object_has_descriptors(p) { if get_accessor_descriptor(p, &name).is_some() { return true; } @@ -576,42 +559,25 @@ pub(crate) unsafe fn class_instance_set_may_intercept( } } -/// #5054: record descriptor installation on the target object itself — -/// `OBJ_FLAG_HAS_DESCRIPTORS` in its GcHeader (travels with the object on -/// evacuation), plus the `Object.prototype` process-global above. Unlike -/// `GLOBAL_DESCRIPTORS_IN_USE`, neither is poisoned by the runtime -/// installing attrs on unrelated builtins (RegExp prototype etc.), so the -/// dynamic-write fast path stays precise. -pub(crate) fn note_descriptor_target(obj: usize) { - note_descriptor_target_keyed(obj, None); -} +pub(crate) use super::key_attrs::AttrsEdit; -/// [`note_descriptor_target`] for a single DATA-descriptor install (#10287): -/// the semantic shape transition is keyed on `(key, attrs)`, so receivers that -/// repeat the same install over the same predecessor share the successor shape -/// instead of each minting a private lineage. Every other descriptor mutation -/// (accessors, batches, clears, prototype changes) keeps a unique generation. +/// A single DATA-descriptor install through the funnel. pub(crate) fn note_data_descriptor_target(obj: usize, key: &str, attrs: PropertyAttrs) { - note_descriptor_target_keyed(obj, Some((key.as_bytes(), attrs.bits))); + note_descriptor_target_edits(obj, &[AttrsEdit::Data(key.as_bytes(), attrs.bits)]); } -/// [`note_descriptor_target`] for an ACCESSOR install (#10287). -/// -/// Keyed on the descriptor's SHAPE — which halves are present — never on the -/// getter/setter identities, which differ per receiver (zod binds a fresh -/// closure per schema). That is sound for the same reason the data form is: -/// the closures live in the per-object accessor table, and every cache that -/// could serve this key refuses a receiver carrying -/// `OBJ_FLAG_HAS_DESCRIPTORS` — the emitted read IC's `data_only` test, the -/// write PIC's blocking mask, the runtime read stub, and the per-key gates -/// here, which report an accessor key as covered. Without this, one lazily -/// installed accessor (zod's `defineLazy`) put every receiver on a private -/// lineage: +41% instructions on a 2,000-receiver fixture. -/// -/// The high bit keeps this encoding disjoint from [`PropertyAttrs`]' three. +/// An ACCESSOR install through the funnel. The keys record which halves are +/// present, never the getter/setter identities, which differ per receiver +/// (zod binds a fresh closure per schema) and live with the receiver. pub(crate) fn note_accessor_descriptor_target(obj: usize, key: &str, acc: &AccessorDescriptor) { - let shape = 0x80u8 | u8::from(acc.get != 0) | (u8::from(acc.set != 0) << 1); - note_descriptor_target_keyed(obj, Some((key.as_bytes(), shape))); + note_descriptor_target_edits( + obj, + &[AttrsEdit::Accessor( + key.as_bytes(), + acc.get != 0, + acc.set != 0, + )], + ); } /// The ONE funnel every descriptor install goes through, and therefore the @@ -621,10 +587,12 @@ pub(crate) fn note_accessor_descriptor_target(obj: usize, key: &str, acc: &Acces /// What it covers, and what it deliberately does not — this is the exact /// scope any shape-only read guard inherits: /// -/// * `GC_TYPE_OBJECT`: sets `OBJ_FLAG_HAS_DESCRIPTORS` **and**, when the -/// receiver is shaped, transitions the shape. A cache entry keyed on the -/// old ShapeId can no longer match, so for these receivers the shape -/// compare subsumes the flag test. +/// * `GC_TYPE_OBJECT`: sets `OBJ_FLAG_HAS_DESCRIPTORS` **and** folds `edits` +/// into the receiver's keys (charter step 3: a key's attributes live with +/// the key, `key_attrs.rs`), so the successor layout — and with it the +/// ShapeId — reports them. A cache entry keyed on the old ShapeId can no +/// longer match, so for these receivers the shape compare subsumes the +/// flag test. /// * **typed arrays**: early return, before either. A small typed array is /// plain-alloc'd without a `GcHeader`, so there is no flag bit to set and /// no `ObjectHeader` to stamp. @@ -638,7 +606,7 @@ pub(crate) fn note_accessor_descriptor_target(obj: usize, key: &str, acc: &Acces /// must still be rejected by KIND. Removing the GC-header load from the read /// path needs their descriptor state carried in the shape word first /// (`rule1_funnel_does_not_cover_non_object_receivers` pins this). -fn note_descriptor_target_keyed(obj: usize, data_install: Option<(&[u8], u8)>) { +pub(crate) fn note_descriptor_target_edits(obj: usize, edits: &[AttrsEdit<'_>]) { if crate::array::object_prototype_addr_matches(obj) { OBJECT_PROTO_DESCRIPTORS.store(true, Ordering::Relaxed); } @@ -650,25 +618,22 @@ fn note_descriptor_target_keyed(obj: usize, data_install: Option<(&[u8], u8)>) { if header.obj_type == crate::gc::GC_TYPE_OBJECT { let header = header as *const crate::gc::GcHeader as *mut crate::gc::GcHeader; (*header)._reserved |= crate::gc::OBJ_FLAG_HAS_DESCRIPTORS; - let object = obj as *mut crate::object::ObjectHeader; - if crate::object::object_is_shaped(object) { - match data_install { - Some((key_bytes, attrs)) => { - crate::object::shapes:: - transition_object_shape_semantics_for_data_descriptor( - object, key_bytes, attrs, - ); - } - None => { - crate::object::shapes::transition_object_shape_semantics(object); - } - } - } + super::key_attrs::apply_edits(obj as *mut crate::object::ObjectHeader, edits); } } } } +/// The receiver-level bookkeeping of a descriptor install, for an object +/// born with a layout whose keys already carry their attributes (an +/// arguments object's `length`/`callee`): the per-object descriptor bit and +/// the process gates, exactly as an install would leave them. +pub(crate) fn note_attrs_born_with_keys(obj: usize) { + note_descriptor_target_edits(obj, &[]); + state().descriptors.property_attrs_in_use.set(true); + GLOBAL_DESCRIPTORS_IN_USE.store(true, Ordering::Relaxed); +} + /// Look up the property descriptor for (obj, key). Returns None if no entry exists, /// in which case the JS default `{ writable: true, enumerable: true, configurable: true }` applies. pub(crate) fn get_property_attrs(obj: usize, key: &str) -> Option { @@ -678,10 +643,27 @@ pub(crate) fn get_property_attrs(obj: usize, key: &str) -> Option // happens in the `string_wrapper_index_attrs` fallback BELOW the table // probe, never as an early return above it. // - // #6759 Phase C2: the meta-record summary proves most misses without - // the `String` build + table probe (and shields a fresh object at a - // recycled address from a dead owner's not-yet-pruned entries). - if may_have_descriptor_entry(obj, key, false) { + // Charter step 3: an ordinary object's attributes live with its keys. No + // table, no summary Bloom, no string build — its shape answers. + let may = match unsafe { descriptor_route(obj) } { + DescriptorRoute::Keys => { + let entry = unsafe { + super::key_attrs::object_key_entry(obj as *const ObjectHeader, key.as_bytes()) + }; + if entry != 0 { + return Some(PropertyAttrs { + bits: super::key_attrs::entry_to_attr_bits(entry), + }); + } + return string_wrapper_index_attrs(obj, key); + } + // #6759 Phase C2: the meta-record summary proves most misses without + // the `String` build + table probe (and shields a fresh object at a + // recycled address from a dead owner's not-yet-pruned entries). + DescriptorRoute::Meta(meta) => unsafe { meta_may_have(meta, key, false) }, + DescriptorRoute::Tables => true, + }; + if may { if let Some(attrs) = state() .descriptors .property_descriptors @@ -866,69 +848,6 @@ fn note_meta_descriptor_key(owner: usize, key: &str, accessor: bool) { } } -/// #6759 Phase C2 per-key fast-path verdict: can the string-keyed -/// descriptor tables hold an entry `(owner, key)`? `false` is -/// authoritative (the probe is skipped); `true` means "probe the table" -/// (a genuine entry, a Bloom collision, or a non-meta-capable owner). -#[inline] -pub(crate) fn may_have_descriptor_entry(owner: usize, key: &str, accessor: bool) -> bool { - unsafe { - let answer = match descriptor_summary_meta(owner) { - Some(meta) => { - if meta.is_null() { - false - } else { - let word = if accessor { - (*meta).accessor_key_bits - } else { - (*meta).attr_key_bits - }; - word & descriptor_key_bit(key) != 0 - } - } - None => true, - }; - // Diagnostic only, and only when the instrument is armed: one relaxed - // load otherwise. Counts the RegExp receivers this filter sees and how - // many it now proves absent — before the meta edge was wired for - // RegExp the second number was 0 by construction. - if crate::hot_diag::regex_on() { - note_regexp_descriptor_probe(owner, answer); - } - answer - } -} - -/// Test-only view of [`may_have_descriptor_entry`], so a test can assert the -/// FILTER's answer rather than only the value it filters to. Without this a -/// test can see that `get_property_attrs` returns `None`, which is equally -/// true when the fast negative never fired — it would pass against a change -/// that did nothing. -#[cfg(test)] -pub(crate) fn test_may_have_descriptor_entry(owner: usize, key: &str, accessor: bool) -> bool { - may_have_descriptor_entry(owner, key, accessor) -} - -/// Diagnostic counter for [`may_have_descriptor_entry`]: is this owner a -/// RegExp cell, and did the summary prove the key absent? Split out and marked -/// cold so the armed check costs the hot path a predictable branch and nothing -/// else. -#[cold] -unsafe fn note_regexp_descriptor_probe(owner: usize, answer: bool) { - let Some(header) = crate::value::addr_class::try_read_gc_header(owner) else { - return; - }; - if header.obj_type != crate::gc::GC_TYPE_REGEXP { - return; - } - crate::hot_diag::regex_with(|d| { - d.desc_regexp_probes += 1; - if !answer { - d.desc_regexp_meta_negative += 1; - } - }); -} - /// #6759 Phase C2: can an OWN string-keyed descriptor (attr or accessor) /// cover the NaN-boxed key `key` on `addr`? Conservative `true` for /// non-string keys and non-meta-capable owners. Callers pair this with @@ -941,6 +860,9 @@ unsafe fn own_descriptor_may_cover_key(addr: usize, key: f64) -> bool { ) else { return true; }; + if super::key_attrs::attrs_live_in_keys(addr) { + return super::key_attrs::object_key_entry(addr as *const ObjectHeader, kb) != 0; + } match descriptor_summary_meta(addr) { Some(meta) => { if meta.is_null() { @@ -977,6 +899,10 @@ pub(crate) unsafe fn own_descriptors_skip_key(addr: usize, key: f64) -> bool { if bytes.first().is_some_and(u8::is_ascii_digit) { return false; } + // Charter step 3: exact from the keys for an ordinary object. + if super::key_attrs::attrs_live_in_keys(addr) { + return super::key_attrs::object_key_entry(addr as *const ObjectHeader, bytes) == 0; + } // Exact when this owner carries descriptors for one key only (zod's // `_zod`): a full-width hash compare, no Bloom, no table probe. if let Some(meta) = descriptor_summary_meta(addr) { @@ -1100,7 +1026,11 @@ pub(crate) unsafe fn plain_custom_prototype_may_intercept(obj_addr: usize, key: if class_id != 0 && class_registry::class_chain_has_instance_accessor(class_id, name) { return true; } - if object_has_descriptors(p) { + if super::key_attrs::attrs_live_in_keys(p) { + if super::key_attrs::object_key_blocks_plain_store(proto_obj, name.as_bytes()) { + return true; + } + } else if object_has_descriptors(p) { if get_accessor_descriptor(p, name).is_some() { return true; } @@ -1237,6 +1167,11 @@ pub(crate) fn set_property_attrs(obj: usize, key: String, attrs: PropertyAttrs) st.descriptors.property_attrs_in_use.set(true); GLOBAL_DESCRIPTORS_IN_USE.store(true, Ordering::Relaxed); disable_inline_guards_for_descriptor_target(obj, &key); + // Charter step 3: an ordinary object's attributes live with its keys + // (recorded by the funnel above) and nowhere else. + if unsafe { super::key_attrs::attrs_live_in_keys(obj) } { + return; + } note_meta_descriptor_key(obj, &key, false); note_young_descriptor_owner(st, obj, None); owner_index_add(&st.descriptors.attr_keys_by_owner, obj, &key); @@ -1250,17 +1185,26 @@ pub(crate) fn set_property_attrs(obj: usize, key: String, attrs: PropertyAttrs) /// No JS runs between entries, so one plan invalidation and semantic shape /// transition retire all prior observations just as repeated installs would. /// The per-key guard, owner index, and GC bookkeeping still run for every key. +#[cfg(test)] pub(crate) fn set_property_attrs_batch(obj: usize, entries: &[(&str, PropertyAttrs)]) { if entries.is_empty() { return; } super::prop_plan::prop_plan_epoch_bump_for_owner(obj); - note_descriptor_target(obj); + let edits: Vec> = entries + .iter() + .map(|&(key, attrs)| AttrsEdit::Data(key.as_bytes(), attrs.bits)) + .collect(); + note_descriptor_target_edits(obj, &edits); let st = state(); st.descriptors.property_attrs_in_use.set(true); GLOBAL_DESCRIPTORS_IN_USE.store(true, Ordering::Relaxed); + let in_keys = unsafe { super::key_attrs::attrs_live_in_keys(obj) }; for &(key, attrs) in entries { disable_inline_guards_for_descriptor_target(obj, key); + if in_keys { + continue; + } note_meta_descriptor_key(obj, key, false); note_young_descriptor_owner(st, obj, None); owner_index_add(&st.descriptors.attr_keys_by_owner, obj, key); @@ -1274,6 +1218,16 @@ pub(crate) fn set_property_attrs_batch(obj: usize, entries: &[(&str, PropertyAtt /// Remove a customized property descriptor for (obj, key), restoring default /// data-property attributes for subsequent writes and reflection. pub(crate) fn clear_property_attrs(obj: usize, key: &str) { + if unsafe { super::key_attrs::attrs_live_in_keys(obj) } { + let entry = unsafe { + super::key_attrs::object_key_entry(obj as *const ObjectHeader, key.as_bytes()) + }; + if entry & super::key_attrs::ENTRY_ATTR_MASK != 0 { + super::prop_plan::prop_plan_epoch_bump_for_owner(obj); + note_descriptor_target_edits(obj, &[AttrsEdit::ClearData(key.as_bytes())]); + } + return; + } let removed = state() .descriptors .property_descriptors @@ -1285,22 +1239,23 @@ pub(crate) fn clear_property_attrs(obj: usize, key: &str) { } owner_index_remove(&state().descriptors.attr_keys_by_owner, obj, key); super::prop_plan::prop_plan_epoch_bump_for_owner(obj); - unsafe { - let object = obj as *mut crate::object::ObjectHeader; - if crate::object::object_is_shaped(object) { - crate::object::shapes::transition_object_shape_semantics_for_descriptor_removal( - object, - key.as_bytes(), - false, - ); - } - } } /// Look up the accessor descriptor (get/set) for (obj, key). pub(crate) fn get_accessor_descriptor(obj: usize, key: &str) -> Option { - // #6759 Phase C2: see `get_property_attrs`. - if !may_have_descriptor_entry(obj, key, true) { + // Charter step 3: an ordinary object's keys say whether `key` is an + // accessor; the closures themselves still live in the owner table. One + // header read picks the filter. + let may = unsafe { + match descriptor_route(obj) { + DescriptorRoute::Keys => { + super::key_attrs::object_key_is_accessor(obj as *const ObjectHeader, key.as_bytes()) + } + DescriptorRoute::Meta(meta) => meta_may_have(meta, key, true), + DescriptorRoute::Tables => true, + } + }; + if !may { return None; } state() @@ -1369,6 +1324,15 @@ pub(crate) fn handle_accessor_descriptor_keys(handle: usize) -> Vec { /// descriptors in the program) walk, per enumeration, to decide whether a /// per-index `enumerable` check was needed at all. pub(crate) fn owner_has_property_descriptors(owner: usize) -> bool { + // Charter step 3: the shape's summary, for an ordinary object: does any + // key carry a non-default data attribute (accessor halves included)? + if unsafe { super::key_attrs::attrs_live_in_keys(owner) } { + return unsafe { super::key_attrs::object_summary(owner as *const ObjectHeader) } + & (super::key_attrs::SUMMARY_NON_WRITABLE + | super::key_attrs::SUMMARY_NON_ENUMERABLE + | super::key_attrs::SUMMARY_NON_CONFIGURABLE) + != 0; + } // Cheap authoritative "no" first: the per-object Bloom summary. if !owner_may_have_descriptor_entries(owner, false) { return false; @@ -1563,10 +1527,38 @@ pub(crate) fn set_accessor_descriptor(obj: usize, key: String, acc: AccessorDesc note_meta_descriptor_key(obj, &key, true); note_young_descriptor_owner(st, obj, Some(&acc)); owner_index_add(&st.descriptors.accessor_keys_by_owner, obj, &key); - st.descriptors + let replaced = st + .descriptors .accessor_descriptors .borrow_mut() - .insert((obj, key), acc); + .insert((obj, key.clone()), acc); + note_accessor_function_replaced(obj, &key, replaced, acc); +} + +/// RULE 1 when an accessor's getter or setter is REPLACED under unchanged +/// attributes: its keys (and so its ShapeId) did not change, but a cache +/// keyed on the ShapeId may hold the old function. +fn note_accessor_function_replaced( + obj: usize, + key: &str, + previous: Option, + now: AccessorDescriptor, +) { + let Some(previous) = previous else { + return; + }; + if previous.get == now.get && previous.set == now.set { + return; + } + unsafe { + if super::key_attrs::attrs_live_in_keys(obj) { + let _no_move = crate::gc::GcSuppressScope::new(); + crate::object::shapes::transition_object_shape_accessor_replaced( + obj as *mut ObjectHeader, + key.as_bytes(), + ); + } + } } /// #9103 follow-up: one-call install of a BRAND-NEW accessor property — the @@ -1623,10 +1615,17 @@ pub(crate) fn install_fresh_accessor_property( attrs: PropertyAttrs, ) { super::prop_plan::prop_plan_epoch_bump_for_owner(obj); - // #10287: one keyed transition covers the pair, exactly as the two-call - // sequence this folds would have produced (the accessor half runs last - // there, so its encoding is the one that survives). - note_accessor_descriptor_target(obj, &key, &acc); + // One edit covers the pair: the keys record both halves, and a key that + // is not yet own arrives WITH them (one trie edge; an in-place append on + // the tip of an attribute backing). + note_descriptor_target_edits( + obj, + &[ + AttrsEdit::Accessor(key.as_bytes(), acc.get != 0, acc.set != 0), + AttrsEdit::Data(key.as_bytes(), attrs.bits), + ], + ); + let in_keys = unsafe { super::key_attrs::attrs_live_in_keys(obj) }; let st = state(); st.descriptors.accessors_in_use.set(true); st.descriptors.property_attrs_in_use.set(true); @@ -1641,7 +1640,10 @@ pub(crate) fn install_fresh_accessor_property( } else { owner_index_push_proven_new(&st.descriptors.accessor_keys_by_owner, obj, &key); } - if attr_bit_was_set { + // The data half of an ordinary object's accessor lives with its + // keys, not in the attribute tables. + if in_keys { + } else if attr_bit_was_set { owner_index_add(&st.descriptors.attr_keys_by_owner, obj, &key); } else { owner_index_push_proven_new(&st.descriptors.attr_keys_by_owner, obj, &key); @@ -1650,17 +1652,21 @@ pub(crate) fn install_fresh_accessor_property( // Non-meta-capable owner: no summary to consult — keep the scans. None => { owner_index_add(&st.descriptors.accessor_keys_by_owner, obj, &key); - owner_index_add(&st.descriptors.attr_keys_by_owner, obj, &key); + if !in_keys { + owner_index_add(&st.descriptors.attr_keys_by_owner, obj, &key); + } } } st.descriptors .accessor_descriptors .borrow_mut() .insert((obj, key.clone()), acc); - st.descriptors - .property_descriptors - .borrow_mut() - .insert((obj, key), attrs); + if !in_keys { + st.descriptors + .property_descriptors + .borrow_mut() + .insert((obj, key), attrs); + } } /// [`owner_index_add`] minus the dedupe scan, for a key @@ -1712,16 +1718,7 @@ pub(crate) fn clear_accessor_descriptor(obj: usize, key: &str) { } owner_index_remove(&state().descriptors.accessor_keys_by_owner, obj, key); super::prop_plan::prop_plan_epoch_bump_for_owner(obj); - unsafe { - let object = obj as *mut crate::object::ObjectHeader; - if crate::object::object_is_shaped(object) { - crate::object::shapes::transition_object_shape_semantics_for_descriptor_removal( - object, - key.as_bytes(), - true, - ); - } - } + note_descriptor_target_edits(obj, &[AttrsEdit::ClearAccessor(key.as_bytes())]); } /// Install a built-in *reflection-only* accessor descriptor for (obj, key) @@ -1746,21 +1743,37 @@ pub(crate) fn set_builtin_accessor_descriptor( attrs: PropertyAttrs, ) { super::prop_plan::prop_plan_epoch_bump_for_owner(obj); - note_descriptor_target(obj); + note_descriptor_target_edits( + obj, + &[ + AttrsEdit::Accessor(key.as_bytes(), acc.get != 0, acc.set != 0), + AttrsEdit::Data(key.as_bytes(), attrs.bits), + ], + ); note_accessor_descriptor_key(&key); + let in_keys = unsafe { super::key_attrs::attrs_live_in_keys(obj) }; // #6759 Phase C2: the meta summary must over-approximate the tables // even for gate-neutral builtin installs — the (unconditionally // consulted) reflection reads now trust a clear bit. note_meta_descriptor_key(obj, &key, true); - note_meta_descriptor_key(obj, &key, false); + if !in_keys { + note_meta_descriptor_key(obj, &key, false); + } let st = state(); note_young_descriptor_owner(st, obj, Some(&acc)); owner_index_add(&st.descriptors.accessor_keys_by_owner, obj, &key); - owner_index_add(&st.descriptors.attr_keys_by_owner, obj, &key); - st.descriptors + let replaced = st + .descriptors .accessor_descriptors .borrow_mut() .insert((obj, key.clone()), acc); + note_accessor_function_replaced(obj, &key, replaced, acc); + // Charter step 3: the data half of an ordinary object's accessor lives + // with its keys (recorded by the funnel above). + if in_keys { + return; + } + owner_index_add(&st.descriptors.attr_keys_by_owner, obj, &key); st.descriptors .property_descriptors .borrow_mut() @@ -1784,7 +1797,10 @@ pub(crate) fn set_builtin_accessor_descriptor( /// down, so the object get/set hot path is unaffected for every program. pub(crate) fn set_builtin_property_attrs(obj: usize, key: String, attrs: PropertyAttrs) { super::prop_plan::prop_plan_epoch_bump_for_owner(obj); - note_descriptor_target(obj); + note_descriptor_target_edits(obj, &[AttrsEdit::Data(key.as_bytes(), attrs.bits)]); + if unsafe { super::key_attrs::attrs_live_in_keys(obj) } { + return; + } // #6759 Phase C2: see `set_builtin_accessor_descriptor`. note_meta_descriptor_key(obj, &key, false); let st = state(); @@ -1796,6 +1812,38 @@ pub(crate) fn set_builtin_property_attrs(obj: usize, key: String, attrs: Propert .insert((obj, key), attrs); } +/// Install a built-in data property WITH its attributes: `obj[key] = value` +/// followed by [`set_builtin_property_attrs`], as one step. On an object +/// whose attributes live with its keys (charter step 3) the key is claimed +/// with its attributes before the value is stored, so a prototype or +/// namespace gaining one method after another appends each key in place — +/// set-then-rewrite would copy the object's key list once per member. +/// Every other owner (a closure constructor, …) takes the two-call path. +/// +/// The claim allocates; callers hold raw receivers across builtin installs, +/// so it runs in a no-move window. +pub(crate) fn define_builtin_data_property( + obj: *mut ObjectHeader, + key: *const crate::StringHeader, + value: f64, + name: String, + attrs: PropertyAttrs, +) { + unsafe { + if super::key_attrs::attrs_live_in_keys(obj as usize) { + let _no_move = crate::gc::GcSuppressScope::new(); + let entry = super::key_attrs::attr_bits_to_entry(attrs.bits); + if entry != 0 { + super::object_ops::ensure_key_in_keys_array_with_entry(obj, key, entry); + } + super::object_ops::define_property_force_store_value(obj, key, value); + } else { + js_object_set_field_by_name(obj, key, value); + } + } + set_builtin_property_attrs(obj as usize, name, attrs); +} + /// Walk the keys array of `obj` and apply the given attribute mask AND filter to every existing key. /// Used by `Object.freeze` (drops `writable` + `configurable`) and `Object.seal` (drops `configurable`). pub(crate) unsafe fn mark_all_keys( @@ -1804,6 +1852,21 @@ pub(crate) unsafe fn mark_all_keys( _drop_enumerable: bool, drop_configurable: bool, ) { + // Charter step 3: an ordinary object's attributes live with its keys, and + // freeze/seal rebuilds them ONCE, from the first key. + if super::key_attrs::attrs_live_in_keys(obj as usize) { + super::prop_plan::prop_plan_epoch_bump_for_owner(obj as usize); + state().descriptors.property_attrs_in_use.set(true); + GLOBAL_DESCRIPTORS_IN_USE.store(true, Ordering::Relaxed); + note_descriptor_target_edits( + obj as usize, + &[AttrsEdit::Integrity { + freeze: drop_writable, + }], + ); + let _ = drop_configurable; + return; + } let keys_view = crate::object::object_keys(obj); let keys = keys_view.arr(); if keys.is_null() { diff --git a/crates/perry-runtime/src/object/descriptor_state/filter.rs b/crates/perry-runtime/src/object/descriptor_state/filter.rs new file mode 100644 index 0000000000..8687a8166c --- /dev/null +++ b/crates/perry-runtime/src/object/descriptor_state/filter.rs @@ -0,0 +1,109 @@ +//! The per-owner filters a descriptor lookup runs before it touches a table: +//! one header read picks the route (an ordinary object's keys, a cell's meta +//! summary, or the tables), split out of `descriptor_state.rs` for the +//! 2000-line cap. + +use super::*; + +/// Where a descriptor lookup for `owner` is answered, from ONE header read. +#[derive(Clone, Copy)] +pub(super) enum DescriptorRoute { + /// An ordinary object: its keys carry every key's attributes (charter step 3). + Keys, + /// A cell with a meta edge: its summary words filter the tables (null = none). + Meta(*mut ObjectMeta), + /// Anything else: probe the tables. + Tables, +} + +#[inline] +pub(super) unsafe fn descriptor_route(owner: usize) -> DescriptorRoute { + let Some(header) = crate::value::addr_class::try_read_gc_header(owner) else { + return DescriptorRoute::Tables; + }; + if header.obj_type == crate::gc::GC_TYPE_OBJECT + && header.gc_flags & crate::gc::GC_FLAG_FORWARDED == 0 + && crate::typedarray::lookup_typed_array_kind(owner).is_none() + { + return DescriptorRoute::Keys; + } + match super::cell_meta_slot_for_header(owner, header) { + Some(slot) => DescriptorRoute::Meta(*slot), + None => DescriptorRoute::Tables, + } +} + +#[inline] +pub(super) unsafe fn meta_may_have(meta: *mut ObjectMeta, key: &str, accessor: bool) -> bool { + if meta.is_null() { + return false; + } + let word = if accessor { + (*meta).accessor_key_bits + } else { + (*meta).attr_key_bits + }; + word & descriptor_key_bit(key) != 0 +} + +/// #6759 Phase C2 per-key fast-path verdict: can the string-keyed +/// descriptor tables hold an entry `(owner, key)`? `false` is +/// authoritative (the probe is skipped); `true` means "probe the table" +/// (a genuine entry, a Bloom collision, or a non-meta-capable owner). +#[inline] +pub(crate) fn may_have_descriptor_entry(owner: usize, key: &str, accessor: bool) -> bool { + unsafe { + let answer = match descriptor_route(owner) { + // Charter step 3: exact for an ordinary object — its keys record + // both halves of every key's descriptor. + DescriptorRoute::Keys => { + let owner = owner as *const ObjectHeader; + return if accessor { + super::key_attrs::object_key_is_accessor(owner, key.as_bytes()) + } else { + super::key_attrs::object_key_entry(owner, key.as_bytes()) != 0 + }; + } + DescriptorRoute::Meta(meta) => meta_may_have(meta, key, accessor), + DescriptorRoute::Tables => true, + }; + // Diagnostic only, and only when the instrument is armed: one relaxed + // load otherwise. Counts the RegExp receivers this filter sees and how + // many it now proves absent — before the meta edge was wired for + // RegExp the second number was 0 by construction. + if crate::hot_diag::regex_on() { + note_regexp_descriptor_probe(owner, answer); + } + answer + } +} + +/// Test-only view of [`may_have_descriptor_entry`], so a test can assert the +/// FILTER's answer rather than only the value it filters to. Without this a +/// test can see that `get_property_attrs` returns `None`, which is equally +/// true when the fast negative never fired — it would pass against a change +/// that did nothing. +#[cfg(test)] +pub(crate) fn test_may_have_descriptor_entry(owner: usize, key: &str, accessor: bool) -> bool { + may_have_descriptor_entry(owner, key, accessor) +} + +/// Diagnostic counter for [`may_have_descriptor_entry`]: is this owner a +/// RegExp cell, and did the summary prove the key absent? Split out and marked +/// cold so the armed check costs the hot path a predictable branch and nothing +/// else. +#[cold] +unsafe fn note_regexp_descriptor_probe(owner: usize, answer: bool) { + let Some(header) = crate::value::addr_class::try_read_gc_header(owner) else { + return; + }; + if header.obj_type != crate::gc::GC_TYPE_REGEXP { + return; + } + crate::hot_diag::regex_with(|d| { + d.desc_regexp_probes += 1; + if !answer { + d.desc_regexp_meta_negative += 1; + } + }); +} diff --git a/crates/perry-runtime/src/object/descriptor_state/gc_scan.rs b/crates/perry-runtime/src/object/descriptor_state/gc_scan.rs index 74b88b586d..666caac5dd 100644 --- a/crates/perry-runtime/src/object/descriptor_state/gc_scan.rs +++ b/crates/perry-runtime/src/object/descriptor_state/gc_scan.rs @@ -258,7 +258,9 @@ mod owner_index_tests { #[test] fn index_mirrors_tables_across_install_redefine_and_delete() { let _lock = crate::gc::global_side_table_test_lock(); - let obj = crate::object::js_object_alloc(0, 0); + // An array owner: its attributes still live in these tables (an + // ordinary object's live with its keys, charter step 3). + let obj = crate::array::js_array_alloc(0); let addr = obj as usize; set_property_attrs(addr, "a".to_string(), PropertyAttrs::new(true, true, true)); @@ -504,8 +506,9 @@ mod string_wrapper_index_attrs_tests { } assert_eq!( test_property_descriptor_entry_count(obj), - 1, - "only `length` is stored; the 11 index descriptors are synthesized" + 0, + "nothing is stored: the 11 index descriptors are synthesized, and \ + `length`'s attributes live with the wrapper's keys (charter step 3)" ); } diff --git a/crates/perry-runtime/src/object/descriptor_state/owner_lifecycle.rs b/crates/perry-runtime/src/object/descriptor_state/owner_lifecycle.rs index 1ae13291a0..e461f1f5cf 100644 --- a/crates/perry-runtime/src/object/descriptor_state/owner_lifecycle.rs +++ b/crates/perry-runtime/src/object/descriptor_state/owner_lifecycle.rs @@ -126,6 +126,14 @@ fn remove_descriptor_owner_entries(st: &crate::state::RuntimeState, owner: usize /// with it the silent no-op this function performed for a HEAP owner while no /// handle had ever taken a descriptor. pub(crate) fn clear_object_descriptors(obj: usize) { + // Charter step 3: an ordinary object's attributes live with its keys; its + // accessor closures still live in the tables and are dropped below. + if unsafe { super::super::key_attrs::attrs_live_in_keys(obj) } + && unsafe { super::super::key_attrs::object_summary(obj as *const ObjectHeader) } != 0 + { + super::super::prop_plan::prop_plan_epoch_bump_for_owner(obj); + note_descriptor_target_edits(obj, &[super::AttrsEdit::ClearAll]); + } let st = state(); let owned_any = st .descriptors @@ -256,7 +264,7 @@ pub(crate) fn transfer_descriptor_owner(old_owner: usize, new_owner: usize) { // carry the source's descriptor state must not silently acquire // descriptors behind an unchanged header and shape. if object_has_descriptors(old_owner) && !object_has_descriptors(new_owner) { - note_descriptor_target(new_owner); + note_descriptor_target_edits(new_owner, &[]); } } diff --git a/crates/perry-runtime/src/object/descriptor_state/tests.rs b/crates/perry-runtime/src/object/descriptor_state/tests.rs new file mode 100644 index 0000000000..f51c97663c --- /dev/null +++ b/crates/perry-runtime/src/object/descriptor_state/tests.rs @@ -0,0 +1,37 @@ +//! The young-owner log sabotage tests for `descriptor_state.rs`. + +mod young_log_sabotage_tests { + use super::super::*; + + #[test] + fn descriptor_log_rederivation_rejects_a_suppressed_setter() { + let _lock = crate::gc::global_side_table_test_lock(); + // An array owner: an ordinary object's data attributes live with its + // keys (charter step 3) and never reach this log. + let owner = crate::array::js_array_alloc(0) as usize; + state().descriptors.young_owners.borrow_mut().clear(); + TEST_SUPPRESS_DESCRIPTOR_YOUNG_NOTE.with(|flag| flag.set(true)); + set_property_attrs( + owner, + "sabotage".to_string(), + PropertyAttrs::new(true, true, true), + ); + TEST_SUPPRESS_DESCRIPTOR_YOUNG_NOTE.with(|flag| flag.set(false)); + let missed = std::panic::catch_unwind(|| { + state() + .descriptors + .young_owners + .borrow() + .debug_assert_logged( + DESCRIPTOR_YOUNG_LOG_NAME, + &relevant_descriptor_owners(state()), + ); + }); + clear_property_attrs(owner, "sabotage"); + state().descriptors.young_owners.borrow_mut().clear(); + assert!( + missed.is_err(), + "sabotage: suppressing set_property_attrs' note must trip completeness" + ); + } +} diff --git a/crates/perry-runtime/src/object/dictionary.rs b/crates/perry-runtime/src/object/dictionary.rs index 917a23f08f..802d7581e6 100644 --- a/crates/perry-runtime/src/object/dictionary.rs +++ b/crates/perry-runtime/src/object/dictionary.rs @@ -414,6 +414,36 @@ unsafe fn meta_of(obj: *const ObjectHeader) -> Option<*mut ObjectMeta> { } } +/// The attribute summary a dictionary receiver's shape carries for its +/// PRIVATE key list: every per-key bit when the list carries attributes +/// (`key_attrs.rs`), else none. Conservative by design — see +/// `shapes::receiver_extra_summary`. +/// +/// # Safety +/// `obj` is null or a live `ObjectHeader`. +#[inline] +pub(crate) unsafe fn private_list_summary(obj: *const ObjectHeader) -> u8 { + if crate::object::key_attrs::keys_attrs(keys_array(obj)).is_null() { + 0 + } else { + crate::object::key_attrs::SUMMARY_KEY_BITS + } +} + +/// Point a dictionary receiver's record at `keys`, the live head of its +/// private list after an in-place edit that may have moved it (taking the +/// attributes reserve can grow the array). Never allocates. +/// +/// # Safety +/// `obj` is a live dictionary receiver and `keys` its private list's head. +pub(crate) unsafe fn replace_private_keys(obj: *const ObjectHeader, keys: *mut ArrayHeader) { + if let Some(meta) = meta_of(obj) { + if (*meta).dictionary_keys != keys as usize as u64 { + store_keys_array(meta, keys); + } + } +} + /// Is this receiver in dictionary mode? /// /// The discriminator is a SHAPE fact first — "my shape publishes no keys" — @@ -476,6 +506,9 @@ unsafe fn restamp_dictionary_shape(obj: *mut ObjectHeader, live_inline_slot_coun Some(d) => d.proto_id, None => shapes::object_proto_id(obj), }; + // Read from the PRIVATE list, not through `is_dictionary`: at the latch + // the shape still publishes the old keys when this runs. + let extra_summary = private_list_summary(obj); let handle = scope.root_raw_mut_ptr(obj); // The mint is the allocating half; the receiver is never nameable across // it (#7341), so there is no pre-call address left to stamp. @@ -488,6 +521,7 @@ unsafe fn restamp_dictionary_shape(obj: *mut ObjectHeader, live_inline_slot_coun shapes::ShapeObjectKind::Ordinary, 0, proto_id, + extra_summary, )) }); shapes::stamp_object_shape_id_with_carrier_note(obj, id); @@ -548,8 +582,18 @@ pub(crate) unsafe fn latch_object_to_dictionary(obj: *mut ObjectHeader) -> bool // a dictionary receiver mutates its array in place, so the copy is // unconditional rather than conditional on the flag: after the latch // the array has exactly one owner by construction. + // Attributes travel with their keys (`key_attrs.rs`): a source that + // carries any gives the copy its own attributes array. + let with_attrs = crate::object::key_attrs::keys_have_entries( + descriptor.keys as usize as *const ArrayHeader, + key_count, + ); let (cloned, obj) = obj_handle.across_mut::(|| { - crate::array::js_array_alloc_pointer_elements(key_count + 4) + if with_attrs { + crate::object::key_attrs::alloc_key_list(key_count + 4, true, true) + } else { + crate::array::js_array_alloc_pointer_elements(key_count + 4) + } }); if cloned.is_null() { return false; @@ -614,6 +658,10 @@ unsafe fn copy_key_list_into(obj: *const ObjectHeader, dst: *mut ArrayHeader, ke // layout covers only the prefix `length` exposes, which is set below. *out.add(i) = (*src.add(i)).to_bits(); } + let dst_attrs = crate::object::key_attrs::keys_attrs(dst); + if !dst_attrs.is_null() { + crate::object::key_attrs::copy_entries(source, 0, dst_attrs, count as u32); + } (*dst).length = count as u32; } 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 3267aa3866..f6de6f4d60 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 @@ -1076,8 +1076,6 @@ pub(super) fn get_field_ic_miss_impl( // `>= GC_HEADER_SIZE + 0x1000 && is_valid_obj_ptr` pair this used to // re-derive). let is_object = gc_kind == Some(crate::gc::GC_TYPE_OBJECT); - let has_own_descriptors = is_object - && gc_header.is_some_and(|h| h._reserved & crate::gc::OBJ_FLAG_HAS_DESCRIPTORS != 0); // #8122: ONE shape-table probe. `object_is_regular` is `GC_TYPE_OBJECT // && !FORWARDED && descriptor.object_kind == Ordinary`; the kind test // was already `GC_TYPE_OBJECT` above, so read the descriptor once and @@ -1183,7 +1181,15 @@ pub(super) fn get_field_ic_miss_impl( // accessors), and the value must be readable through // `overflow_get` right now — if it is not, priming // would cache a lie. - if !has_own_descriptors && (i as u32) < crate::proxy::IC_SLOT_OVERFLOW_BIT { + // Charter step 3: the receiver's keys say whether THIS + // key is an accessor; an attribute on another key + // changes nothing a read of this slot depends on. + let key_is_accessor = + shape.summary & crate::object::key_attrs::SUMMARY_ACCESSOR != 0 + && crate::object::key_attrs::keys_entry(keys, i as u32) + & crate::object::key_attrs::ENTRY_ACCESSOR + != 0; + if !key_is_accessor && (i as u32) < crate::proxy::IC_SLOT_OVERFLOW_BIT { if let Some(bits) = crate::object::overflow_get(obj as usize, i) { if bits != crate::value::TAG_HOLE { let stamp = crate::object::shapes::object_shape_stamp(obj); @@ -1229,23 +1235,33 @@ pub(super) fn get_field_ic_miss_impl( // shape id — no second probe. let stamp = crate::object::shapes::object_shape_stamp(obj); let token = (stamp as u64 | crate::object::shapes::PIC_ID_TOKEN_BIT) as i64; - // A descriptor-bearing receiver primes only when the key is - // proved a plain data slot. The one proof that exists is - // the object-backed Array subclass's named-prefix proof - // (every declared key accessor-free on THIS receiver; its - // unrelated `length` descriptor must not make `arch.sset` - // permanently generic). It is a PRIME-TIME proof only: the - // site stores nothing but `(ShapeId, slot)`, and a ShapeId - // implies its descriptor semantics (every descriptor event - // mints a new generation, #10824/#10287), so the pair is a - // fact about this one shape for as long as the id lives. - // The proof builder is gated by an existing ObjectMeta - // pointer so ordinary objects retain the old miss cost. It - // runs whenever it ran before (it also publishes the token - // on the object's meta, which `element_shape` consumes). + // An accessor key primes only when the key is proved a + // plain data slot by the object-backed Array subclass's + // named-prefix proof (every declared key accessor-free on + // THIS receiver). It is a PRIME-TIME proof only: the site + // stores nothing but `(ShapeId, slot)`, and a ShapeId + // implies its attributes (charter step 3: they live with + // the keys), so the pair is a fact about this one shape for + // as long as the id lives. The proof builder is gated by an + // existing ObjectMeta pointer so ordinary objects retain the + // old miss cost. It runs whenever it ran before (it also + // publishes the token on the object's meta, which + // `element_shape` consumes). let named_prefix_proved = !(*obj).meta.is_null() && crate::array::array_subclass_named_prefix_token_for_slot(obj, i) != 0; - if has_own_descriptors && !named_prefix_proved { + // Charter step 3 (#10871): the SHAPE says whether THIS key + // is an accessor. A descriptor on another key of the + // object changes nothing a read of this slot depends on. + let key_is_accessor = + shape.summary & crate::object::key_attrs::SUMMARY_ACCESSOR != 0 + && crate::object::key_attrs::keys_entry(keys, i as u32) + & crate::object::key_attrs::ENTRY_ACCESSOR + != 0; + if key_is_accessor && !named_prefix_proved { + #[cfg(feature = "attr-census")] + crate::object::attr_census::note_global( + "ic.read_prime_declined.accessor_key", + ); miss_reason = R::OwnDescriptorFallthrough; break; } 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 64bdbc4a26..f86c4d877e 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 @@ -866,6 +866,9 @@ pub(crate) fn set_field_by_name_object_tail( let grown = crate::array::js_array_push(keys, JSValue::string_ptr(key as *mut _)); let _ = owned.get_raw_mut_ptr::(); + // Its attributes, if it carries any, grow with it. + let grown = + crate::object::key_attrs::owned_note_append(grown, key_count as u32, 0); crate::object::ObjectKeys::owned(grown) } }; @@ -1053,6 +1056,8 @@ pub(crate) fn set_field_by_name_object_tail( let owned = scope.root_raw_mut_ptr(keys); let grown = crate::array::js_array_push(keys, JSValue::string_ptr(key as *mut _)); let _ = owned.get_raw_mut_ptr::(); + // Its attributes, if it carries any, grow with it. + let grown = crate::object::key_attrs::owned_note_append(grown, key_count as u32, 0); crate::object::ObjectKeys::owned(grown) } }; diff --git a/crates/perry-runtime/src/object/global_this/generator.rs b/crates/perry-runtime/src/object/global_this/generator.rs index 7ed5215e9b..cce7e6ab83 100644 --- a/crates/perry-runtime/src/object/global_this/generator.rs +++ b/crates/perry-runtime/src/object/global_this/generator.rs @@ -260,8 +260,7 @@ pub(crate) fn set_intrinsic_data_prop( attrs: super::super::PropertyAttrs, ) { let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); - js_object_set_field_by_name(obj, key, value); - super::super::set_builtin_property_attrs(obj as usize, name.to_string(), attrs); + super::super::define_builtin_data_property(obj, key, value, name.to_string(), attrs); } /// Set `obj[Symbol.toStringTag] = tag` (the descriptor is the spec default diff --git a/crates/perry-runtime/src/object/global_this/install_static.rs b/crates/perry-runtime/src/object/global_this/install_static.rs index 41b85ec437..b45a562ce6 100644 --- a/crates/perry-runtime/src/object/global_this/install_static.rs +++ b/crates/perry-runtime/src/object/global_this/install_static.rs @@ -248,9 +248,10 @@ pub(crate) fn install_constructor_static_with_call_arity( super::super::native_module::set_builtin_closure_non_constructable(closure as usize); let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); let value = crate::value::js_nanbox_pointer(closure as i64); - js_object_set_field_by_name(ctor as *mut ObjectHeader, key, value); - super::super::set_builtin_property_attrs( - ctor as usize, + super::super::define_builtin_data_property( + ctor as *mut ObjectHeader, + key, + value, name.to_string(), super::super::PropertyAttrs::new(true, false, true), ); @@ -274,9 +275,10 @@ pub(crate) fn install_number_static_data_properties(ctor: *mut crate::closure::C ]; for (name, value) in props { let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); - js_object_set_field_by_name(ctor as *mut ObjectHeader, key, value); - super::super::set_builtin_property_attrs( - ctor as usize, + super::super::define_builtin_data_property( + ctor as *mut ObjectHeader, + key, + value, name.to_string(), super::super::PropertyAttrs::new(false, false, false), ); @@ -752,14 +754,15 @@ pub(crate) fn install_proto_method( super::super::native_module::set_builtin_closure_non_constructable(closure as usize); let key = crate::string::js_string_from_bytes(method_name.as_ptr(), method_name.len() as u32); let value = crate::value::js_nanbox_pointer(closure as i64); - js_object_set_field_by_name(proto_obj, key, value); // Built-in prototype methods are `{ writable: true, enumerable: false, // configurable: true }` per spec. Record that descriptor (reflection-only, // no hot-path gate flip) so `Object.getOwnPropertyDescriptor`, `Object.keys` // and `for-in` all observe them as non-enumerable — Test262's `verifyProperty` // checks every built-in method this way. See `set_builtin_property_attrs`. - super::super::set_builtin_property_attrs( - proto_obj as usize, + super::super::define_builtin_data_property( + proto_obj, + key, + value, method_name.to_string(), super::super::PropertyAttrs::new(true, false, true), ); @@ -796,9 +799,10 @@ pub(crate) fn install_proto_method_alias( value: f64, ) { let key = crate::string::js_string_from_bytes(alias_name.as_ptr(), alias_name.len() as u32); - js_object_set_field_by_name(proto_obj, key, value); - super::super::set_builtin_property_attrs( - proto_obj as usize, + super::super::define_builtin_data_property( + proto_obj, + key, + value, alias_name.to_string(), super::super::PropertyAttrs::new(true, false, true), ); @@ -836,9 +840,10 @@ pub(crate) fn install_proto_method_rest_with_length( super::super::native_module::set_builtin_closure_non_constructable(closure as usize); let key = crate::string::js_string_from_bytes(method_name.as_ptr(), method_name.len() as u32); let value = crate::value::js_nanbox_pointer(closure as i64); - js_object_set_field_by_name(proto_obj, key, value); - super::super::set_builtin_property_attrs( - proto_obj as usize, + super::super::define_builtin_data_property( + proto_obj, + key, + value, method_name.to_string(), super::super::PropertyAttrs::new(true, false, true), ); diff --git a/crates/perry-runtime/src/object/global_this/math_temporal.rs b/crates/perry-runtime/src/object/global_this/math_temporal.rs index d6ad2f1072..a3019bde96 100644 --- a/crates/perry-runtime/src/object/global_this/math_temporal.rs +++ b/crates/perry-runtime/src/object/global_this/math_temporal.rs @@ -479,9 +479,10 @@ fn install_temporal_proto_method(proto: *mut ObjectHeader, kind: u8, name: &str, super::super::native_module::set_builtin_closure_length(cl, spec_length); super::super::native_module::set_builtin_closure_non_constructable(cl); let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); - js_object_set_field_by_name(proto, key, crate::value::js_nanbox_pointer(c as i64)); - super::super::set_builtin_property_attrs( - proto as usize, + super::super::define_builtin_data_property( + proto, + key, + crate::value::js_nanbox_pointer(c as i64), name.to_string(), super::super::PropertyAttrs::new(true, false, true), ); @@ -523,18 +524,20 @@ fn install_temporal_prototype( // ctor.prototype = proto ({ writable:false, enumerable:false, configurable:false }) let proto_value = crate::value::js_nanbox_pointer(proto as i64); let proto_key = crate::string::js_string_from_bytes(b"prototype".as_ptr(), 9); - js_object_set_field_by_name(ctor as *mut ObjectHeader, proto_key, proto_value); - super::super::set_builtin_property_attrs( - ctor as usize, + super::super::define_builtin_data_property( + ctor as *mut ObjectHeader, + proto_key, + proto_value, "prototype".to_string(), super::super::PropertyAttrs::new(false, false, false), ); // proto.constructor = ctor ({ writable:true, enumerable:false, configurable:true }) let ctor_value = crate::value::js_nanbox_pointer(ctor as i64); let ctor_key = crate::string::js_string_from_bytes(b"constructor".as_ptr(), 11); - js_object_set_field_by_name(proto, ctor_key, ctor_value); - super::super::set_builtin_property_attrs( - proto as usize, + super::super::define_builtin_data_property( + proto, + ctor_key, + ctor_value, "constructor".to_string(), super::super::PropertyAttrs::new(true, false, true), ); @@ -556,9 +559,10 @@ fn install_temporal_constructor( super::super::native_module::set_builtin_closure_length(closure as usize, spec_length); let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); let value = crate::value::js_nanbox_pointer(closure as i64); - js_object_set_field_by_name(ns_obj, key, value); - super::super::set_builtin_property_attrs( - ns_obj as usize, + super::super::define_builtin_data_property( + ns_obj, + key, + value, name.to_string(), super::super::PropertyAttrs::new(true, false, true), ); @@ -1465,9 +1469,10 @@ pub(crate) fn install_temporal_namespace(ns_obj: *mut ObjectHeader) { // Temporal.Now namespace (#4689) let now_value = build_temporal_now_namespace(); let now_key = crate::string::js_string_from_bytes(b"Now".as_ptr(), 3); - js_object_set_field_by_name(ns_obj, now_key, now_value); - super::super::set_builtin_property_attrs( - ns_obj as usize, + super::super::define_builtin_data_property( + ns_obj, + now_key, + now_value, "Now".to_string(), super::super::PropertyAttrs::new(true, false, true), ); diff --git a/crates/perry-runtime/src/object/global_this/populate.rs b/crates/perry-runtime/src/object/global_this/populate.rs index d8b7d95db5..eec3a64201 100644 --- a/crates/perry-runtime/src/object/global_this/populate.rs +++ b/crates/perry-runtime/src/object/global_this/populate.rs @@ -105,9 +105,10 @@ pub(crate) fn populate_global_this_builtins(singleton_at_entry: *mut ObjectHeade let name = b"globalThis"; let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); let value = crate::value::js_nanbox_pointer(singleton() as i64); - js_object_set_field_by_name(singleton(), key, value); - super::super::set_builtin_property_attrs( - singleton() as usize, + super::super::define_builtin_data_property( + singleton(), + key, + value, "globalThis".to_string(), super::super::PropertyAttrs::new(true, false, true), ); @@ -120,9 +121,10 @@ pub(crate) fn populate_global_this_builtins(singleton_at_entry: *mut ObjectHeade let name = b"global"; let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); let value = crate::value::js_nanbox_pointer(singleton() as i64); - js_object_set_field_by_name(singleton(), key, value); - super::super::set_builtin_property_attrs( - singleton() as usize, + super::super::define_builtin_data_property( + singleton(), + key, + value, "global".to_string(), super::super::PropertyAttrs::new(true, true, true), ); @@ -182,9 +184,10 @@ pub(crate) fn populate_global_this_builtins(singleton_at_entry: *mut ObjectHeade ); } } - js_object_set_field_by_name(singleton(), name_key, ctor_value); - super::super::set_builtin_property_attrs( - singleton() as usize, + super::super::define_builtin_data_property( + singleton(), + name_key, + ctor_value, name.to_string(), super::super::PropertyAttrs::new(true, false, true), ); @@ -346,9 +349,10 @@ pub(crate) fn populate_global_this_builtins(singleton_at_entry: *mut ObjectHeade }; if !proto_obj.is_null() { let proto_value = crate::value::js_nanbox_pointer(proto_obj as i64); - js_object_set_field_by_name(closure_ptr as *mut ObjectHeader, proto_key, proto_value); - super::super::set_builtin_property_attrs( - closure_ptr as usize, + super::super::define_builtin_data_property( + closure_ptr as *mut ObjectHeader, + proto_key, + proto_value, "prototype".to_string(), super::super::PropertyAttrs::new(false, false, false), ); @@ -356,16 +360,18 @@ pub(crate) fn populate_global_this_builtins(singleton_at_entry: *mut ObjectHeade b"constructor".as_ptr(), "constructor".len() as u32, ); - js_object_set_field_by_name(proto_obj, ctor_key, ctor_value); - super::super::set_builtin_property_attrs( - proto_obj as usize, + super::super::define_builtin_data_property( + proto_obj, + ctor_key, + ctor_value, "constructor".to_string(), super::super::PropertyAttrs::new(true, false, true), ); if is_web_fetch_constructor(name) { - js_object_set_field_by_name(proto_obj, ctor_key, ctor_value); - super::super::set_builtin_property_attrs( - proto_obj as usize, + super::super::define_builtin_data_property( + proto_obj, + ctor_key, + ctor_value, "constructor".to_string(), super::super::PropertyAttrs::new(true, false, true), ); @@ -373,9 +379,10 @@ pub(crate) fn populate_global_this_builtins(singleton_at_entry: *mut ObjectHeade if name == "Array" { let constructor_key = crate::string::js_string_from_bytes(b"constructor".as_ptr(), 11); - js_object_set_field_by_name(proto_obj, constructor_key, ctor_value); - super::super::set_builtin_property_attrs( - proto_obj as usize, + super::super::define_builtin_data_property( + proto_obj, + constructor_key, + ctor_value, "constructor".to_string(), super::super::PropertyAttrs::new(true, false, true), ); @@ -519,9 +526,10 @@ pub(crate) fn populate_global_this_builtins(singleton_at_entry: *mut ObjectHeade b"BYTES_PER_ELEMENT".as_ptr(), b"BYTES_PER_ELEMENT".len() as u32, ); - js_object_set_field_by_name(target, bpe_key, bytes); - super::super::set_builtin_property_attrs( - target as usize, + super::super::define_builtin_data_property( + target, + bpe_key, + bytes, "BYTES_PER_ELEMENT".to_string(), bpe_attrs, ); @@ -531,9 +539,10 @@ pub(crate) fn populate_global_this_builtins(singleton_at_entry: *mut ObjectHeade let name_bytes = name.as_bytes(); let name_key = crate::string::js_string_from_bytes(name_bytes.as_ptr(), name_bytes.len() as u32); - js_object_set_field_by_name(singleton(), name_key, ctor_value); - super::super::set_builtin_property_attrs( - singleton() as usize, + super::super::define_builtin_data_property( + singleton(), + name_key, + ctor_value, name.to_string(), super::super::PropertyAttrs::new(true, false, true), ); @@ -662,9 +671,10 @@ pub(crate) fn populate_global_this_builtins(singleton_at_entry: *mut ObjectHeade let name_key = crate::string::js_string_from_bytes(name_bytes.as_ptr(), name_bytes.len() as u32); let fn_value = crate::value::js_nanbox_pointer(closure_ptr as i64); - js_object_set_field_by_name(singleton(), name_key, fn_value); - super::super::set_builtin_property_attrs( - singleton() as usize, + super::super::define_builtin_data_property( + singleton(), + name_key, + fn_value, name.to_string(), super::super::PropertyAttrs::new(true, enumerable, true), ); @@ -739,9 +749,10 @@ pub(crate) fn populate_global_this_builtins(singleton_at_entry: *mut ObjectHeade } crate::value::js_nanbox_pointer(ns_obj as i64) }; - js_object_set_field_by_name(singleton(), name_key, ns_value); - super::super::set_builtin_property_attrs( - singleton() as usize, + super::super::define_builtin_data_property( + singleton(), + name_key, + ns_value, name.to_string(), super::super::PropertyAttrs::new(true, false, true), ); @@ -815,18 +826,20 @@ pub(crate) fn populate_global_this_builtins(singleton_at_entry: *mut ObjectHeade let non_writable = super::super::PropertyAttrs::new(false, false, false); for (name, value) in [("NaN", f64::NAN), ("Infinity", f64::INFINITY)] { let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); - js_object_set_field_by_name(singleton(), key, value); - super::super::set_builtin_property_attrs( - singleton() as usize, + super::super::define_builtin_data_property( + singleton(), + key, + value, name.to_string(), non_writable, ); } let undef_key = crate::string::js_string_from_bytes(b"undefined".as_ptr(), 9); let undef_val = f64::from_bits(crate::value::TAG_UNDEFINED); - js_object_set_field_by_name(singleton(), undef_key, undef_val); - super::super::set_builtin_property_attrs( - singleton() as usize, + super::super::define_builtin_data_property( + singleton(), + undef_key, + undef_val, "undefined".to_string(), super::super::PropertyAttrs::new(false, false, false), ); @@ -900,13 +913,10 @@ fn alias_typed_array_proto_to_string(singleton_at_entry: *mut ObjectHeader) { let to_string_handle = scope.root_nanbox_u64(to_string_fn.bits()); let ts_key2 = crate::string::js_string_from_bytes(b"toString".as_ptr(), 8); - js_object_set_field_by_name( + super::super::define_builtin_data_property( ta_proto_handle.get_raw_mut_ptr::(), ts_key2, f64::from_bits(to_string_handle.get_nanbox_u64()), - ); - super::super::set_builtin_property_attrs( - ta_proto_handle.get_raw_mut_ptr::() as usize, "toString".to_string(), super::super::PropertyAttrs::new(true, false, true), ); @@ -1009,9 +1019,10 @@ fn install_error_static_methods(ctor: *mut crate::closure::ClosureHeader) { let key = crate::string::js_string_from_bytes(b"captureStackTrace".as_ptr(), 17); let value = crate::value::js_nanbox_pointer(closure as i64); - js_object_set_field_by_name(ctor as *mut ObjectHeader, key, value); - super::super::set_builtin_property_attrs( - ctor as usize, + super::super::define_builtin_data_property( + ctor as *mut ObjectHeader, + key, + value, "captureStackTrace".to_string(), super::super::PropertyAttrs::new(true, false, true), ); @@ -1036,9 +1047,10 @@ fn install_error_static_methods(ctor: *mut crate::closure::ClosureHeader) { // frame count. Node's default is 10; Perry's stacks are coarse but the // property must read as a number and be writable. let limit_key = crate::string::js_string_from_bytes(b"stackTraceLimit".as_ptr(), 15); - js_object_set_field_by_name(ctor as *mut ObjectHeader, limit_key, 10.0); - super::super::set_builtin_property_attrs( - ctor as usize, + super::super::define_builtin_data_property( + ctor as *mut ObjectHeader, + limit_key, + 10.0, "stackTraceLimit".to_string(), super::super::PropertyAttrs::new(true, true, true), ); @@ -1061,9 +1073,10 @@ fn install_error_static_fn( super::super::native_module::set_bound_native_closure_name(closure, name); let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); let value = crate::value::js_nanbox_pointer(closure as i64); - js_object_set_field_by_name(ctor as *mut ObjectHeader, key, value); - super::super::set_builtin_property_attrs( - ctor as usize, + super::super::define_builtin_data_property( + ctor as *mut ObjectHeader, + key, + value, name.to_string(), super::super::PropertyAttrs::new(true, false, true), ); diff --git a/crates/perry-runtime/src/object/global_this/proto_methods.rs b/crates/perry-runtime/src/object/global_this/proto_methods.rs index 0a77bf48cf..03f6b9bcb2 100644 --- a/crates/perry-runtime/src/object/global_this/proto_methods.rs +++ b/crates/perry-runtime/src/object/global_this/proto_methods.rs @@ -647,25 +647,19 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: // the built-in-function property order Test262 checks. { let len_key = crate::string::js_string_from_bytes(b"length".as_ptr(), 6); - js_object_set_field_by_name( + super::super::define_builtin_data_property( proto_obj, len_key, f64::from_bits(JSValue::number(0.0).bits()), - ); - super::super::set_builtin_property_attrs( - proto_obj as usize, "length".to_string(), super::super::PropertyAttrs::new(false, false, true), ); let empty = crate::string::js_string_from_bytes(b"".as_ptr(), 0); let name_key = crate::string::js_string_from_bytes(b"name".as_ptr(), 4); - js_object_set_field_by_name( + super::super::define_builtin_data_property( proto_obj, name_key, f64::from_bits(JSValue::string_ptr(empty).bits()), - ); - super::super::set_builtin_property_attrs( - proto_obj as usize, "name".to_string(), super::super::PropertyAttrs::new(false, false, true), ); @@ -926,13 +920,10 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: "username", ] { let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); - js_object_set_field_by_name( + super::super::define_builtin_data_property( proto_obj, key, f64::from_bits(crate::value::TAG_UNDEFINED), - ); - super::super::set_builtin_property_attrs( - proto_obj as usize, name.to_string(), super::super::PropertyAttrs::new(false, false, true), ); @@ -1459,26 +1450,20 @@ pub(crate) fn install_error_prototype_data_properties( let name_key = crate::string::js_string_from_bytes(b"name".as_ptr(), 4); let name_value = crate::string::js_string_from_bytes(name.as_bytes().as_ptr(), name.len() as u32); - js_object_set_field_by_name( + super::super::define_builtin_data_property( proto_obj, name_key, crate::value::js_nanbox_string(name_value as i64), - ); - super::super::set_builtin_property_attrs( - proto_obj as usize, "name".to_string(), super::super::PropertyAttrs::new(true, false, true), ); let message_key = crate::string::js_string_from_bytes(b"message".as_ptr(), 7); let message_value = crate::string::js_string_from_bytes(b"".as_ptr(), 0); - js_object_set_field_by_name( + super::super::define_builtin_data_property( proto_obj, message_key, crate::value::js_nanbox_string(message_value as i64), - ); - super::super::set_builtin_property_attrs( - proto_obj as usize, "message".to_string(), super::super::PropertyAttrs::new(true, false, true), ); diff --git a/crates/perry-runtime/src/object/global_this/typed_array.rs b/crates/perry-runtime/src/object/global_this/typed_array.rs index 320c7d9c9e..44b7d88a61 100644 --- a/crates/perry-runtime/src/object/global_this/typed_array.rs +++ b/crates/perry-runtime/src/object/global_this/typed_array.rs @@ -670,20 +670,18 @@ pub(crate) fn ensure_typed_array_intrinsic( let proto_key = crate::string::js_string_from_bytes(proto_key_bytes.as_ptr(), proto_key_bytes.len() as u32); let proto_value = crate::value::js_nanbox_pointer(proto as i64); - js_object_set_field_by_name(ctor as *mut ObjectHeader, proto_key, proto_value); - super::super::set_builtin_property_attrs( - ctor as usize, + super::super::define_builtin_data_property( + ctor as *mut ObjectHeader, + proto_key, + proto_value, "prototype".to_string(), super::super::PropertyAttrs::new(false, false, false), ); let constructor_key = crate::string::js_string_from_bytes(b"constructor".as_ptr(), 11); - js_object_set_field_by_name( + super::super::define_builtin_data_property( proto, constructor_key, crate::value::js_nanbox_pointer(ctor as i64), - ); - super::super::set_builtin_property_attrs( - proto as usize, "constructor".to_string(), super::super::PropertyAttrs::new(true, false, true), ); diff --git a/crates/perry-runtime/src/object/global_this_webassembly.rs b/crates/perry-runtime/src/object/global_this_webassembly.rs index 69f065abd4..30b4f4f18c 100644 --- a/crates/perry-runtime/src/object/global_this_webassembly.rs +++ b/crates/perry-runtime/src/object/global_this_webassembly.rs @@ -1105,18 +1105,20 @@ fn install_webassembly_constructor( if !proto_obj.is_null() { let proto_key = crate::string::js_string_from_bytes(b"prototype".as_ptr(), 9); let proto_value = crate::value::js_nanbox_pointer(proto_obj as i64); - js_object_set_field_by_name(closure as *mut ObjectHeader, proto_key, proto_value); - super::super::set_builtin_property_attrs( - closure as usize, + super::super::define_builtin_data_property( + closure as *mut ObjectHeader, + proto_key, + proto_value, "prototype".to_string(), super::super::PropertyAttrs::new(false, false, false), ); let ctor_key = crate::string::js_string_from_bytes(b"constructor".as_ptr(), 11); let ctor_value = crate::value::js_nanbox_pointer(closure as i64); - js_object_set_field_by_name(proto_obj, ctor_key, ctor_value); - super::super::set_builtin_property_attrs( - proto_obj as usize, + super::super::define_builtin_data_property( + proto_obj, + ctor_key, + ctor_value, "constructor".to_string(), super::super::PropertyAttrs::new(true, false, true), ); @@ -1124,9 +1126,10 @@ fn install_webassembly_constructor( let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); let value = crate::value::js_nanbox_pointer(closure as i64); - js_object_set_field_by_name(ns_obj, key, value); - super::super::set_builtin_property_attrs( - ns_obj as usize, + super::super::define_builtin_data_property( + ns_obj, + key, + value, name.to_string(), super::super::PropertyAttrs::new(true, false, true), ); @@ -1162,14 +1165,15 @@ fn install_webassembly_static_fn( ); let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); let value = crate::value::js_nanbox_pointer(closure as i64); - js_object_set_field_by_name(obj, key, value); // Node (v26) descriptor for the namespace FUNCTION members and the // `Module.*` metadata statics: { writable: true, enumerable: true, // configurable: true } — while the CONSTRUCTOR members are installed // non-enumerable (see `install_webassembly_constructor`). Verified // against the webassembly-namespace.ts node-suite fixture. - super::super::set_builtin_property_attrs( - obj as usize, + super::super::define_builtin_data_property( + obj, + key, + value, name.to_string(), super::super::PropertyAttrs::new(true, enumerable, true), ); @@ -1224,9 +1228,10 @@ fn install_webassembly_proto_data( return; } let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); - js_object_set_field_by_name(proto, key, value); - super::super::set_builtin_property_attrs( - proto as usize, + super::super::define_builtin_data_property( + proto, + key, + value, name.to_string(), super::super::PropertyAttrs::new(false, false, true), ); @@ -1239,26 +1244,20 @@ fn install_webassembly_error_proto_data(ctor: *mut crate::closure::ClosureHeader } let name_key = crate::string::js_string_from_bytes(b"name".as_ptr(), 4); let name_string = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); - js_object_set_field_by_name( + super::super::define_builtin_data_property( proto, name_key, crate::value::js_nanbox_string(name_string as i64), - ); - super::super::set_builtin_property_attrs( - proto as usize, "name".to_string(), super::super::PropertyAttrs::new(true, false, true), ); let message_key = crate::string::js_string_from_bytes(b"message".as_ptr(), 7); let message_string = crate::string::js_string_from_bytes(b"".as_ptr(), 0); - js_object_set_field_by_name( + super::super::define_builtin_data_property( proto, message_key, crate::value::js_nanbox_string(message_string as i64), - ); - super::super::set_builtin_property_attrs( - proto as usize, "message".to_string(), super::super::PropertyAttrs::new(true, false, true), ); @@ -1278,8 +1277,7 @@ fn install_webassembly_object_property( } let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); let value = crate::value::js_nanbox_pointer(obj as i64); - js_object_set_field_by_name(ns_obj, key, value); - super::super::set_builtin_property_attrs(ns_obj as usize, name.to_string(), attrs); + super::super::define_builtin_data_property(ns_obj, key, value, name.to_string(), attrs); } // ──────────────────────────────────────────────────────────────────────── diff --git a/crates/perry-runtime/src/object/key_attrs.rs b/crates/perry-runtime/src/object/key_attrs.rs new file mode 100644 index 0000000000..03f912dea9 --- /dev/null +++ b/crates/perry-runtime/src/object/key_attrs.rs @@ -0,0 +1,1065 @@ +//! Charter step 3: a key's ATTRIBUTES live with the key, in the keys array +//! (V8's descriptor arrays). +//! +//! # Representation +//! +//! Every key position carries one *entry* byte. `0` is the default — a +//! writable, enumerable, configurable data property — so a list whose keys +//! are all default carries nothing, hashes and validates exactly as it did +//! before attributes existed, and costs a plain object nothing: +//! +//! | bit | meaning | +//! |---|---| +//! | [`ENTRY_NON_WRITABLE`] | `[[Writable]]` false (for an accessor: the internal writable bit) | +//! | [`ENTRY_NON_ENUMERABLE`] | `[[Enumerable]]` false | +//! | [`ENTRY_NON_CONFIGURABLE`] | `[[Configurable]]` false | +//! | [`ENTRY_ACCESSOR`] | an accessor property | +//! | [`ENTRY_HAS_GET`] / [`ENTRY_HAS_SET`] | which accessor halves exist | +//! +//! A keys array that carries any non-default entry owns an *attributes +//! array*: a pointer-free `GC_TYPE_ARRAY` with one element per key position, +//! `INT32_TAG | cumulative << 8 | entry`. `cumulative` is the OR of +//! [`entry_summary`] over positions `0..=i`, so the summary of any PREFIX of +//! the list — which is what a shape names, since one canonical backing serves +//! a whole growth chain (`canonical_keys.rs`) — is one load: +//! [`keys_summary`]`(keys, count)`. +//! +//! The pointer to the attributes array lives in the keys array's first +//! physical slot, in front of logical element 0, with `GC_ARRAY_NAMED_PROPS` +//! set: exactly the reserve #10166 gives an Array's named properties +//! (`array/named_props.rs`). So nothing new exists for the collector: the +//! reserve word is already emitted as a fixed child slot of every flagged +//! array (`gc::layout_slot_visit`), `js_array_grow` already re-reserves and +//! copies it, and every element reader already adds the front offset +//! (`array_elements_ptr`, #10939). The front slot count is +//! [`KEYS_ATTRS_FRONT_SLOTS`]; nothing may hand-type it. +//! +//! # Why reusing `GC_ARRAY_NAMED_PROPS` on a keys array is sound +//! +//! `GcHeader::_reserved` has no free bit for arrays (`gc/types.rs`). The +//! named-properties bit means "the first physical slot is a traced reserve +//! word" to the collector and to growth, and that is all a keys array needs. +//! Its other meaning — "this Array has expando properties, read them from a +//! pairs array" — is consulted only by the JS-facing Array operations +//! (`arr.foo`, `Object.keys(arr)`, …), and a keys array is NEVER a JS value: +//! it is reached only through a shape record or a dictionary receiver's meta +//! record, and every producer that hands keys to JS (`Object.keys`, +//! `getOwnPropertyNames`, `for…in`) builds a fresh result array. A keys +//! array therefore never meets a reader of the pairs interpretation. +//! +//! # Mutability follows the keys +//! +//! A canonical backing's attributes prefix is as immutable as its key prefix: +//! the only in-place write is the tip append past every published count. An +//! owned list (a dictionary receiver's private list) is mutated in place with +//! its keys; its cumulative words are then recomputed from the edited +//! position, so they stay exact. + +use crate::array::ArrayHeader; +use crate::value::{INT32_TAG, POINTER_MASK, POINTER_TAG, TAG_MASK}; + +/// `[[Writable]]` is false. On an accessor this is the internal writable bit +/// (`install_fresh_accessor_property` keeps it true for a fresh accessor). +pub(crate) const ENTRY_NON_WRITABLE: u8 = 0x01; +/// `[[Enumerable]]` is false. +pub(crate) const ENTRY_NON_ENUMERABLE: u8 = 0x02; +/// `[[Configurable]]` is false. +pub(crate) const ENTRY_NON_CONFIGURABLE: u8 = 0x04; +/// The key is an accessor property; its getter/setter pair lives with the +/// receiver, never with the shape. +pub(crate) const ENTRY_ACCESSOR: u8 = 0x08; +/// The accessor has a getter. +pub(crate) const ENTRY_HAS_GET: u8 = 0x10; +/// The accessor has a setter. +pub(crate) const ENTRY_HAS_SET: u8 = 0x20; +/// The three `PropertyAttrs` bits, inverted. +pub(crate) const ENTRY_ATTR_MASK: u8 = + ENTRY_NON_WRITABLE | ENTRY_NON_ENUMERABLE | ENTRY_NON_CONFIGURABLE; +/// The accessor half of an entry. +pub(crate) const ENTRY_ACCESSOR_MASK: u8 = ENTRY_ACCESSOR | ENTRY_HAS_GET | ENTRY_HAS_SET; + +/// Summary bits: what a list (or a prefix of one) MAY contain. +/// +/// Some key is an accessor. +pub(crate) const SUMMARY_ACCESSOR: u8 = 0x01; +/// Some DATA key is not writable. +pub(crate) const SUMMARY_NON_WRITABLE: u8 = 0x02; +/// Some key is not enumerable. +pub(crate) const SUMMARY_NON_ENUMERABLE: u8 = 0x04; +/// Some key is not configurable. +pub(crate) const SUMMARY_NON_CONFIGURABLE: u8 = 0x08; +/// Every per-key summary bit: a list with none of them is all default. +pub(crate) const SUMMARY_KEY_BITS: u8 = + SUMMARY_ACCESSOR | SUMMARY_NON_WRITABLE | SUMMARY_NON_ENUMERABLE | SUMMARY_NON_CONFIGURABLE; +/// Bits a plain data store must see clear on every hop of a prototype chain. +pub(crate) const SUMMARY_BLOCKS_STORE: u8 = SUMMARY_ACCESSOR | SUMMARY_NON_WRITABLE; + +/// Physical slots a keys array that carries attributes reserves in front of +/// logical element 0: the attributes pointer. The ONE spelling of this +/// number; generated code that reads a keys array positionally must add it +/// for such an array (it is `array_front_offset`). +pub(crate) const KEYS_ATTRS_FRONT_SLOTS: usize = 1; + +/// The summary bits one entry contributes. +#[inline] +pub(crate) const fn entry_summary(entry: u8) -> u8 { + let mut s = 0; + if entry & ENTRY_ACCESSOR != 0 { + s |= SUMMARY_ACCESSOR; + } else if entry & ENTRY_NON_WRITABLE != 0 { + s |= SUMMARY_NON_WRITABLE; + } + if entry & ENTRY_NON_ENUMERABLE != 0 { + s |= SUMMARY_NON_ENUMERABLE; + } + if entry & ENTRY_NON_CONFIGURABLE != 0 { + s |= SUMMARY_NON_CONFIGURABLE; + } + s +} + +/// The entry's data-attribute half from `PropertyAttrs` bits (W=1, E=2, C=4). +#[inline] +pub(crate) const fn attr_bits_to_entry(attrs: u8) -> u8 { + !attrs & ENTRY_ATTR_MASK +} + +/// The `PropertyAttrs` bits (W=1, E=2, C=4) an entry records. +#[inline] +pub(crate) const fn entry_to_attr_bits(entry: u8) -> u8 { + !entry & ENTRY_ATTR_MASK +} + +/// A key with this entry is a plain WRITABLE DATA property — the only kind an +/// inline store may overwrite without the runtime. +#[inline] +pub(crate) const fn entry_is_plain_writable_data(entry: u8) -> bool { + entry & (ENTRY_ACCESSOR | ENTRY_NON_WRITABLE) == 0 +} + +/// One element of an attributes array: the entry at this position, and the +/// CUMULATIVE facts of positions `0..=i` — the summary, and two 16-bit Bloom +/// filters over the keys that carry a non-default entry / an accessor. The +/// cumulative words make a prefix's answers one load: the summary of a shape +/// naming `count` keys is element `count - 1`'s, and so are its filters. +#[derive(Clone, Copy)] +struct Word { + entry: u8, + summary: u8, + entry_bloom: u16, + accessor_bloom: u16, +} + +impl Word { + const EMPTY: Word = Word { + entry: 0, + summary: 0, + entry_bloom: 0, + accessor_bloom: 0, + }; + + #[inline] + const fn encode(self) -> u64 { + INT32_TAG + | self.entry as u64 + | (self.summary as u64) << 8 + | (self.entry_bloom as u64) << 16 + | (self.accessor_bloom as u64) << 32 + } + + #[inline] + const fn decode(word: u64) -> Word { + Word { + entry: word as u8, + summary: (word >> 8) as u8, + entry_bloom: (word >> 16) as u16, + accessor_bloom: (word >> 32) as u16, + } + } + + /// This position's word: `entry` for `key`, after the cumulative `prev`. + #[inline] + unsafe fn after(prev: Word, entry: u8, key: crate::JSValue) -> Word { + let mut w = Word { entry, ..prev }; + if entry != 0 { + w.summary |= entry_summary(entry); + let bit = key_bloom_bit(key); + w.entry_bloom |= bit; + if entry & ENTRY_ACCESSOR != 0 { + w.accessor_bloom |= bit; + } + } + w + } +} + +/// The Bloom bit a key contributes: one of 16, from the key's content hash. +/// A key without a string form (a hole, a symbol) contributes none — it +/// carries no entry either. +#[inline] +unsafe fn key_bloom_bit(key: crate::JSValue) -> u16 { + let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + match crate::string::js_string_key_bytes(key, &mut sso) { + Some(bytes) => bloom_bit_of_bytes(bytes), + None => 0, + } +} + +#[inline] +fn bloom_bit_of_bytes(bytes: &[u8]) -> u16 { + let h = crate::object::keys_lookup::key_bytes_hash(bytes.as_ptr(), bytes.len()); + 1u16 << (h >> 60) +} + +/// The attributes array of `keys`, or null when the list carries none. +/// +/// The keys arrays a shape record or a dictionary receiver names are live, +/// resolved heads, so this is one header load and one reserve-word load. A +/// forwarded head (a list read across its own growth) is resolved first. +/// +/// # Safety +/// `keys` is null or a live keys array. +#[inline] +pub(crate) unsafe fn keys_attrs(keys: *const ArrayHeader) -> *mut ArrayHeader { + if keys.is_null() { + return std::ptr::null_mut(); + } + let header = crate::gc::header_from_trusted_user_ptr(keys.cast()); + let mut keys = keys; + if (*header).gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 { + keys = crate::array::clean_arr_ptr(keys); + if keys.is_null() { + return std::ptr::null_mut(); + } + } + if crate::array::array_object_flags_resolved(keys) & crate::gc::GC_ARRAY_NAMED_PROPS == 0 { + return std::ptr::null_mut(); + } + let word = *crate::array::array_named_props_slot(keys); + if word & TAG_MASK != POINTER_TAG { + return std::ptr::null_mut(); + } + (word & POINTER_MASK) as *mut ArrayHeader +} + +/// [`keys_attrs`] for a keys word that may not be a real array (a shape +/// record minted by a test from a synthetic address): the header is read +/// through the ownership-checking reader first. +/// +/// # Safety +/// `keys` is any address. +#[inline] +pub(crate) unsafe fn keys_attrs_checked(keys: *const ArrayHeader) -> *mut ArrayHeader { + match crate::value::addr_class::try_read_gc_header(keys as usize) { + Some(h) if h.obj_type == crate::gc::GC_TYPE_ARRAY => keys_attrs(keys), + _ => std::ptr::null_mut(), + } +} + +/// The raw element words of an attributes array and how many are written. +#[inline] +unsafe fn words(attrs: *const ArrayHeader) -> (*mut u64, usize) { + ( + crate::array::array_elements_ptr(attrs), + ((*attrs).length.min((*attrs).capacity)) as usize, + ) +} + +/// The cumulative word of the first `count` positions (EMPTY for none). +#[inline] +unsafe fn prefix_word(attrs: *const ArrayHeader, count: u32) -> Word { + let (w, len) = words(attrs); + let last = (count as usize).min(len); + if last == 0 { + Word::EMPTY + } else { + Word::decode(*w.add(last - 1)) + } +} + +/// The entry at key position `pos` of `keys` (0 = default, also for a +/// position past the attributes array). +/// +/// # Safety +/// As [`keys_attrs`]. +#[inline] +pub(crate) unsafe fn keys_entry(keys: *const ArrayHeader, pos: u32) -> u8 { + let attrs = keys_attrs(keys); + if attrs.is_null() { + return 0; + } + let (w, len) = words(attrs); + if (pos as usize) < len { + Word::decode(*w.add(pos as usize)).entry + } else { + 0 + } +} + +/// The summary of the first `count` keys of `keys`: exact for a canonical +/// list, an over-approximation for an owned list edited in place. +/// +/// # Safety +/// As [`keys_attrs`]. +#[inline] +pub(crate) unsafe fn keys_summary(keys: *const ArrayHeader, count: u32) -> u8 { + if count == 0 { + return 0; + } + let attrs = keys_attrs(keys); + if attrs.is_null() { + return 0; + } + prefix_word(attrs, count).summary & SUMMARY_KEY_BITS +} + +/// [`keys_summary`] through [`keys_attrs_checked`]. +/// +/// # Safety +/// `keys` is any address. +#[inline] +pub(crate) unsafe fn keys_summary_checked(keys: *const ArrayHeader, count: u32) -> u8 { + if count == 0 { + return 0; + } + let attrs = keys_attrs_checked(keys); + if attrs.is_null() { + return 0; + } + prefix_word(attrs, count).summary & SUMMARY_KEY_BITS +} + +/// Can `key` carry a non-default entry (`accessor`: an accessor entry) among +/// the first `count` positions of `keys`? `false` is authoritative: the +/// prefix's Bloom filter has no bit for it. +/// +/// # Safety +/// As [`keys_attrs`]. +#[inline] +pub(crate) unsafe fn keys_may_carry( + keys: *const ArrayHeader, + count: u32, + key: &[u8], + accessor: bool, +) -> bool { + let attrs = keys_attrs(keys); + if attrs.is_null() { + return false; + } + let w = prefix_word(attrs, count); + let bloom = if accessor { + w.accessor_bloom + } else { + w.entry_bloom + }; + bloom & bloom_bit_of_bytes(key) != 0 +} + +/// Does any of the first `count` positions carry a non-default entry? +/// +/// # Safety +/// As [`keys_attrs`]. +#[inline] +pub(crate) unsafe fn keys_have_entries(keys: *const ArrayHeader, count: u32) -> bool { + let attrs = keys_attrs(keys); + if attrs.is_null() { + return false; + } + let (w, len) = words(attrs); + (0..(count as usize).min(len)).any(|i| Word::decode(*w.add(i)).entry != 0) +} + +/// Allocate an attributes array with room for `capacity` positions. +/// Pointer-free: its elements are INT32 boxes. +/// +/// # Safety +/// May collect: the caller roots what it holds. +pub(crate) unsafe fn alloc_attrs(capacity: u32) -> *mut ArrayHeader { + #[cfg(feature = "attr-census")] + crate::object::attr_census::note_global_n( + "bytes.attrs_array", + (capacity.max(1) as u64 + 1) * 8 + std::mem::size_of::() as u64, + ); + crate::array::js_array_alloc_key_list(capacity.max(1), false) +} + +/// Write `entry` for `key` at `pos`, whose predecessors `0..pos` are already +/// written, and extend the written length to cover it. Allocation-free. +/// +/// # Safety +/// `attrs` is a live attributes array with capacity past `pos`, `pos <=` its +/// written length, and `key` is the key at `pos` (live). +#[inline] +pub(crate) unsafe fn attrs_write( + attrs: *mut ArrayHeader, + pos: u32, + entry: u8, + key: crate::JSValue, +) { + let (w, len) = words(attrs); + debug_assert!((pos as usize) <= len && pos < (*attrs).capacity); + let before = if pos == 0 { + Word::EMPTY + } else { + Word::decode(*w.add(pos as usize - 1)) + }; + // GC_STORE_AUDIT(POINTER_FREE): an INT32 box into a pointer-free array. + *w.add(pos as usize) = Word::after(before, entry, key).encode(); + if pos as usize == len { + (*attrs).length = pos + 1; + } +} + +/// The key slot at `pos` of `keys`, or `undefined` past its initialized end. +#[inline] +unsafe fn key_at(keys: *const ArrayHeader, pos: u32) -> crate::JSValue { + let (slots, len) = crate::object::keys_array_dense_slots(keys); + if (pos as usize) < len { + crate::JSValue::from_bits((*slots.add(pos as usize)).to_bits()) + } else { + crate::JSValue::from_bits(crate::value::TAG_UNDEFINED) + } +} + +/// Rewrite the entry at `pos` of an OWNED list's attributes array and +/// recompute every cumulative word from there. O(length - pos). +/// +/// # Safety +/// `keys` is live and exclusively owned, `attrs` its attributes array, and +/// `pos` is below the attributes' written length. +pub(crate) unsafe fn attrs_set_owned( + keys: *const ArrayHeader, + attrs: *mut ArrayHeader, + pos: u32, + entry: u8, +) { + let (w, len) = words(attrs); + debug_assert!((pos as usize) < len); + // GC_STORE_AUDIT(POINTER_FREE): an INT32 box into a pointer-free array. + *w.add(pos as usize) = Word { + entry, + ..Word::EMPTY + } + .encode(); + recompute_cumulative(keys, attrs, pos); +} + +/// Recompute the cumulative words of `attrs` from position `from` on, reading +/// the keys from `keys`. +/// +/// # Safety +/// `keys` is live and `attrs` its attributes array. +pub(crate) unsafe fn recompute_cumulative( + keys: *const ArrayHeader, + attrs: *mut ArrayHeader, + from: u32, +) { + let (w, len) = words(attrs); + let mut prev = if from == 0 { + Word::EMPTY + } else { + Word::decode(*w.add(from as usize - 1)) + }; + for i in from as usize..len { + let entry = Word::decode(*w.add(i)).entry; + prev = Word::after(prev, entry, key_at(keys, i as u32)); + // GC_STORE_AUDIT(POINTER_FREE): an INT32 box into a pointer-free array. + *w.add(i) = prev.encode(); + } +} + +/// Attach `attrs` to `keys`, whose attributes reserve must already exist. +/// Barriered: an old keys array may take a young attributes array. +/// +/// # Safety +/// `keys` is a live, forwarding-resolved keys array carrying +/// `GC_ARRAY_NAMED_PROPS`; `attrs` is a live attributes array. +#[inline] +pub(crate) unsafe fn attach_attrs(keys: *mut ArrayHeader, attrs: *mut ArrayHeader) { + debug_assert!( + crate::array::array_object_flags_resolved(keys) & crate::gc::GC_ARRAY_NAMED_PROPS != 0 + ); + crate::array::store_named_props_word( + keys, + crate::value::js_nanbox_pointer(attrs as i64).to_bits(), + ); +} + +/// Allocate a fresh key list with room for `capacity` keys, and — when +/// `with_attrs` — its attributes array, attached, with nothing written. The +/// canonical trie's and the copy sites' one allocator for a list that must +/// carry attributes. +/// +/// # Safety +/// May collect: the caller roots everything it holds. Nothing is held across +/// the two allocations here except through a handle. +pub(crate) unsafe fn alloc_key_list( + capacity: u32, + all_ptr: bool, + with_attrs: bool, +) -> *mut ArrayHeader { + if !with_attrs { + return crate::array::js_array_alloc_key_list(capacity, all_ptr); + } + #[cfg(feature = "attr-census")] + crate::object::attr_census::note_global_n( + "bytes.key_list_with_attrs", + (capacity as u64 + 2) * 8 + std::mem::size_of::() as u64, + ); + let scope = crate::gc::RuntimeHandleScope::new(); + let attrs = alloc_attrs(capacity); + let attrs_handle = scope.root_raw_mut_ptr(attrs); + let (keys, attrs) = attrs_handle.across_mut::(|| { + crate::array::js_array_alloc_key_list_reserved(capacity, all_ptr) + }); + attach_attrs(keys, attrs); + keys +} + +/// Copy the entries of `src_keys` positions `from..from + n` into `dst_attrs` +/// at the SAME positions, which must be the written end of `dst_attrs`. A +/// source without attributes contributes default entries. +/// +/// # Safety +/// Both arrays are live, `dst_attrs` has the capacity, and nothing allocates. +pub(crate) unsafe fn copy_entries( + src_keys: *const ArrayHeader, + from: u32, + dst_attrs: *mut ArrayHeader, + n: u32, +) { + let src = keys_attrs(src_keys); + let (sw, slen) = if src.is_null() { + (std::ptr::null_mut(), 0) + } else { + words(src) + }; + for pos in from..from + n { + let entry = if (pos as usize) < slen { + Word::decode(*sw.add(pos as usize)).entry + } else { + 0 + }; + attrs_write(dst_attrs, pos, entry, key_at(src_keys, pos)); + } +} + +// --------------------------------------------------------------------------- +// Object-level readers: what a receiver's own key's attributes are. +// --------------------------------------------------------------------------- + +/// Is `addr` a heap object whose attributes live with its keys? The ONE +/// predicate that routes a descriptor operation to the keys instead of the +/// owner-keyed tables, so installs and reads cannot disagree about where an +/// owner's attributes are. Every other cell kind (arrays, closures, exotic +/// cells, typed arrays) keeps the tables for now. +/// +/// # Safety +/// `addr` is any address; it is classified through the ownership-checking +/// header reader. +#[inline] +pub(crate) unsafe fn attrs_live_in_keys(addr: usize) -> bool { + let Some(header) = crate::value::addr_class::try_read_gc_header(addr) else { + return false; + }; + header.obj_type == crate::gc::GC_TYPE_OBJECT + && header.gc_flags & crate::gc::GC_FLAG_FORWARDED == 0 + && crate::typedarray::lookup_typed_array_kind(addr).is_none() +} + +/// The attribute summary of `obj`'s shape (0 when unshaped). One load. +/// +/// # Safety +/// `obj` is a live `ObjectHeader`. +#[inline] +pub(crate) unsafe fn object_summary(obj: *const crate::object::ObjectHeader) -> u8 { + crate::object::shapes::object_shape_record(obj) + .map(|r| r.summary()) + .unwrap_or(0) +} + +/// The entry `obj`'s own key `key` carries: 0 when the key is default or +/// absent. Three filters answer most keys without a lookup: the shape's +/// summary (an all-default receiver), then the key-list prefix's Bloom filter +/// (a key no entry was ever written for). +/// +/// # Safety +/// `obj` is a live `ObjectHeader`. +#[inline] +pub(crate) unsafe fn object_key_entry(obj: *const crate::object::ObjectHeader, key: &[u8]) -> u8 { + if object_summary(obj) & SUMMARY_KEY_BITS == 0 { + return 0; + } + object_key_entry_filtered(obj, key, false) +} + +/// Is `obj`'s own key `key` an accessor? The accessor filters answer most +/// keys alone: a receiver with a few accessors pays a lookup only for them. +/// +/// # Safety +/// `obj` is a live `ObjectHeader`. +#[inline] +pub(crate) unsafe fn object_key_is_accessor( + obj: *const crate::object::ObjectHeader, + key: &[u8], +) -> bool { + if object_summary(obj) & SUMMARY_ACCESSOR == 0 { + return false; + } + object_key_entry_filtered(obj, key, true) & ENTRY_ACCESSOR != 0 +} + +#[inline(never)] +unsafe fn object_key_entry_filtered( + obj: *const crate::object::ObjectHeader, + key: &[u8], + accessor: bool, +) -> u8 { + let keys = crate::object::object_keys(obj); + if keys.is_null() || !keys_may_carry(keys.arr(), keys.count(), key, accessor) { + return 0; + } + #[cfg(feature = "attr-census")] + crate::object::attr_census::note_global("read.key_entry_lookup"); + match crate::object::keys_find_slot_by_bytes(keys.arr(), keys.count(), key) { + Some(pos) => keys_entry(keys.arr(), pos), + None => 0, + } +} + +/// [`object_key_entry`] for a key held as a string header. An undecodable key +/// reads as an accessor — the conservative answer for every caller (a read +/// declines to prime, a store declines). +/// +/// # Safety +/// `obj` is a live `ObjectHeader`; `key` is null or a live string header. +#[inline] +pub(crate) unsafe fn object_key_entry_for_string( + obj: *const crate::object::ObjectHeader, + key: *const crate::StringHeader, +) -> u8 { + if object_summary(obj) & SUMMARY_KEY_BITS == 0 { + return 0; + } + if key.is_null() { + return 0; + } + let boxed = + crate::value::JSValue::from_bits(crate::value::js_nanbox_string(key as i64).to_bits()); + let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + match crate::string::js_string_key_bytes(boxed, &mut sso) { + Some(bytes) => object_key_entry_filtered(obj, bytes, false), + None => ENTRY_ACCESSOR, + } +} + +/// Can a plain data store of `key` be intercepted by `obj` — is the key an +/// accessor or a non-writable data property there? A prototype whose summary +/// has neither answers without a key lookup: every class prototype whose +/// members are only non-enumerable methods. +/// +/// # Safety +/// `obj` is a live `ObjectHeader`. +#[inline] +pub(crate) unsafe fn object_key_blocks_plain_store( + obj: *const crate::object::ObjectHeader, + key: &[u8], +) -> bool { + if object_summary(obj) & SUMMARY_BLOCKS_STORE == 0 { + #[cfg(feature = "attr-census")] + crate::object::attr_census::note_global("read.store_check_summary_clear"); + return false; + } + !entry_is_plain_writable_data(object_key_entry_filtered(obj, key, false)) +} + +// --------------------------------------------------------------------------- +// Mutation: every attribute change of a receiver whose attributes live with +// its keys goes through [`apply_edits`]. +// --------------------------------------------------------------------------- + +/// One attribute change, as the keys record it. Every descriptor mutation +/// describes itself with these, and [`apply_edits`] folds them into the +/// receiver's keys, so the successor layout is a function of WHAT changed. +#[derive(Clone, Copy, Debug)] +pub(crate) enum AttrsEdit<'a> { + /// `key`'s data attributes become these `PropertyAttrs` bits (W=1, E=2, + /// C=4). An accessor half is kept. + Data(&'a [u8], u8), + /// `key`'s data attributes return to the default. An accessor half is kept. + ClearData(&'a [u8]), + /// `key` becomes an accessor with a getter / a setter. Data attributes are + /// kept. + Accessor(&'a [u8], bool, bool), + /// `key` stops being an accessor. Data attributes are kept. + ClearAccessor(&'a [u8]), + /// Every string key becomes non-configurable, and with `freeze` also + /// non-writable (`Object.freeze` / `Object.seal`). + Integrity { freeze: bool }, + /// Every key returns to the default. + ClearAll, +} + +impl AttrsEdit<'_> { + /// The single key this edit is about, or `None` for a whole-list edit. + #[inline] + pub(crate) fn key(&self) -> Option<&[u8]> { + match *self { + AttrsEdit::Data(k, _) + | AttrsEdit::ClearData(k) + | AttrsEdit::Accessor(k, _, _) + | AttrsEdit::ClearAccessor(k) => Some(k), + AttrsEdit::Integrity { .. } | AttrsEdit::ClearAll => None, + } + } + + /// This edit applied to an entry. + #[inline] + pub(crate) fn apply(self, old: u8) -> u8 { + match self { + AttrsEdit::Data(_, bits) => (old & ENTRY_ACCESSOR_MASK) | attr_bits_to_entry(bits), + AttrsEdit::ClearData(_) => old & ENTRY_ACCESSOR_MASK, + AttrsEdit::Accessor(_, get, set) => { + let mut e = (old & ENTRY_ATTR_MASK) | ENTRY_ACCESSOR; + if get { + e |= ENTRY_HAS_GET; + } + if set { + e |= ENTRY_HAS_SET; + } + e + } + AttrsEdit::ClearAccessor(_) => old & ENTRY_ATTR_MASK, + AttrsEdit::Integrity { freeze } => { + old | ENTRY_NON_CONFIGURABLE | if freeze { ENTRY_NON_WRITABLE } else { 0 } + } + AttrsEdit::ClearAll => 0, + } + } +} + +/// The key bytes of a keys-array slot, when it holds a string key. +#[inline] +unsafe fn slot_key_bytes<'b>( + slot: crate::JSValue, + sso: &'b mut [u8; crate::value::SHORT_STRING_MAX_LEN], +) -> Option<&'b [u8]> { + crate::string::js_string_key_bytes(slot, sso) +} + +/// The entry position `pos` of a list takes after `edits`, from `old`. +/// Whole-list edits apply to string keys only (a hole or a symbol slot keeps +/// its entry: symbol attributes are the symbol tables'). +#[inline] +unsafe fn fold_edits(edits: &[AttrsEdit<'_>], slot: crate::JSValue, old: u8) -> u8 { + let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let Some(bytes) = slot_key_bytes(slot, &mut sso) else { + return old; + }; + let mut entry = old; + for edit in edits { + match edit.key() { + Some(k) if k != bytes => {} + _ => entry = edit.apply(entry), + } + } + entry +} + +/// Apply `edits` to `obj`'s keys: the one place an attribute change reaches +/// the layout. +/// +/// * A key that is not yet own is CLAIMED with its entry — the attribute +/// always has a key, and the key enters the list in insertion order, where +/// a later `[[OwnPropertyKeys]]` expects it. A key arriving with its +/// attributes is one trie edge ([`canonical_keys::extend_key_with_entry`]), +/// so an `exports` object gaining a getter per re-export appends in place. +/// * Existing keys of a shared layout are REBUILT from the first changed +/// position ([`canonical_keys::rebuild_with_entries`]): the prefix is +/// shared, the rest re-appended — the successor layout is canonical, so +/// receivers making the same change share it. +/// * A dictionary receiver edits its private list in place and draws a fresh +/// dictionary generation, as for every other change to it. +/// +/// Runs in a no-move window: the allocations here are a few small arrays, +/// and every caller of the descriptor installers holds the receiver as a raw +/// address across the call, as it always could. +/// +/// # Safety +/// `obj` is a live `GC_TYPE_OBJECT` that is not a typed array. +pub(crate) unsafe fn apply_edits(obj: *mut crate::object::ObjectHeader, edits: &[AttrsEdit<'_>]) { + if edits.is_empty() || !crate::object::object_is_shaped(obj) { + return; + } + #[cfg(feature = "attr-census")] + crate::object::attr_census::note_kind("edit.apply", obj as usize); + let _no_move = crate::gc::GcSuppressScope::new(); + // 1. Claim absent keys, each with its folded entry. + for (i, edit) in edits.iter().enumerate() { + let Some(key) = edit.key() else { + continue; + }; + if edits[..i].iter().any(|e| e.key() == Some(key)) { + continue; // folded with its first occurrence + } + let keys = crate::object::object_keys(obj); + if !keys.is_null() + && crate::object::keys_find_slot_by_bytes(keys.arr(), keys.count(), key).is_some() + { + continue; + } + let entry = edits + .iter() + .filter(|e| e.key().is_none() || e.key() == Some(key)) + .fold(0u8, |acc, e| e.apply(acc)); + if entry == 0 { + // A default entry on an absent key changes nothing: the key is not + // own, and when it becomes own it is born default. + continue; + } + #[cfg(feature = "attr-census")] + crate::object::attr_census::note_kind("edit.claim_absent_key", obj as usize); + let name = crate::string::js_string_from_bytes(key.as_ptr(), key.len() as u32); + crate::object::object_ops::ensure_key_in_keys_array_with_entry(obj, name, entry); + } + // 2. Existing keys: which positions change, and to what. + let keys = crate::object::object_keys(obj); + if keys.is_null() { + return; + } + let (slots, available) = crate::object::keys_array_dense_slots(keys.arr()); + let count = (keys.count() as usize).min(available); + let changes_at = |pos: usize| -> bool { + let slot = crate::JSValue::from_bits((*slots.add(pos)).to_bits()); + let old = keys_entry(keys.arr(), pos as u32); + fold_edits(edits, slot, old) != old + }; + let mut first_change: Option = None; + if edits.iter().all(|e| e.key().is_some()) { + // Keyed edits touch only their keys' positions: find them, rather + // than walk the list (an install on a wide object is one lookup). + for edit in edits { + let key = edit.key().unwrap_or_default(); + if let Some(pos) = crate::object::keys_find_slot_by_bytes(keys.arr(), count as u32, key) + { + if first_change.map_or(true, |f| pos < f) && changes_at(pos as usize) { + first_change = Some(pos); + } + } + } + } else { + first_change = (0..count) + .find(|&pos| changes_at(pos)) + .map(|pos| pos as u32); + } + let Some(from) = first_change else { + #[cfg(feature = "attr-census")] + crate::object::attr_census::note_global("edit.no_change"); + return; + }; + if crate::object::dictionary::is_dictionary(obj) { + #[cfg(feature = "attr-census")] + crate::object::attr_census::note_global("edit.dictionary_in_place"); + edit_private_list(obj, keys.arr(), from, count as u32, edits); + crate::object::shapes::transition_object_shape_semantics(obj); + return; + } + let Some(proof) = crate::object::canonical_keys::SharedLayout::of_receiver(obj) else { + return; + }; + let rebuilt = + crate::object::canonical_keys::rebuild_with_entries(&proof, keys, from, |_, slot, old| { + fold_edits(edits, slot, old) + }); + crate::object::set_object_keys(obj, rebuilt.view()); +} + +/// Edit a dictionary receiver's PRIVATE list in place from position `from`. +unsafe fn edit_private_list( + obj: *mut crate::object::ObjectHeader, + keys: *mut ArrayHeader, + from: u32, + count: u32, + edits: &[AttrsEdit<'_>], +) { + let mut keys = ensure_owned_attrs(keys, count); + if count > 0 { + // Cover every position, whatever appended to the list before. + let last = keys_entry(keys, count - 1); + keys = owned_note_append(keys, count - 1, last); + } + crate::object::dictionary::replace_private_keys(obj, keys); + let attrs = keys_attrs(keys); + let (slots, _) = crate::object::keys_array_dense_slots(keys); + let (w, _) = words(attrs); + for pos in from..count { + let slot = crate::JSValue::from_bits((*slots.add(pos as usize)).to_bits()); + let old = Word::decode(*w.add(pos as usize)).entry; + let new = fold_edits(edits, slot, old); + // GC_STORE_AUDIT(POINTER_FREE): an INT32 box into a pointer-free array. + *w.add(pos as usize) = Word { + entry: new, + ..Word::EMPTY + } + .encode(); + } + recompute_cumulative(keys, attrs, from); +} + +/// Give an OWNED key list (a dictionary receiver's private list) an +/// attributes array covering its first `count` positions, all default, if it +/// has none, with room for the list's capacity. Returns the list's live head: +/// taking the reserve slot moves the elements up one slot inside the +/// allocation, or grows it when it is full. +/// +/// # Safety +/// `keys` is a live, exclusively owned keys array. May allocate: the caller +/// roots what it holds (or runs in a no-move window). +pub(crate) unsafe fn ensure_owned_attrs(keys: *mut ArrayHeader, count: u32) -> *mut ArrayHeader { + let keys = crate::array::clean_arr_ptr_mut(keys); + if !keys_attrs(keys).is_null() { + return keys; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let keys_handle = scope.root_raw_mut_ptr(keys); + let capacity = (*keys).capacity.max(count); + let (attrs, keys) = keys_handle.across_mut::(|| alloc_attrs(capacity + 1)); + let attrs_handle = scope.root_raw_mut_ptr(attrs); + let (keys, attrs) = + attrs_handle.across_mut::(|| crate::array::ensure_named_props_slot(keys)); + assert!( + !keys.is_null(), + "an owned key list must be able to take its attributes reserve" + ); + for pos in 0..count { + attrs_write(attrs, pos, 0, key_at(keys, pos)); + } + attach_attrs(keys, attrs); + keys +} + +/// Record the entry of the key an OWNED list just appended at `pos`. A list +/// without attributes stays without them for a default entry. Returns the +/// list's live head (see [`ensure_owned_attrs`]). +/// +/// # Safety +/// As [`ensure_owned_attrs`]; `pos` is the appended key's position, and the +/// list holds `pos + 1` keys. +pub(crate) unsafe fn owned_note_append( + keys: *mut ArrayHeader, + pos: u32, + entry: u8, +) -> *mut ArrayHeader { + let mut keys = crate::array::clean_arr_ptr_mut(keys); + let mut attrs = keys_attrs(keys); + if attrs.is_null() { + if entry == 0 { + return keys; + } + #[cfg(feature = "attr-census")] + crate::object::attr_census::note_global("keys.owned_attach_attrs"); + keys = ensure_owned_attrs(keys, pos); + attrs = keys_attrs(keys); + } + let written = (*attrs).length; + if pos >= (*attrs).capacity { + // Full: copy the prefix into a larger array. + let scope = crate::gc::RuntimeHandleScope::new(); + let keys_handle = scope.root_raw_mut_ptr(keys); + let old_handle = scope.root_raw_mut_ptr(attrs); + let capacity = (pos + 1).max((*keys).capacity).max(pos + pos / 2 + 1); + let ((fresh, keys_now), old) = old_handle.across_mut::(|| { + keys_handle.across_mut::(|| alloc_attrs(capacity)) + }); + let keep = written.min(pos); + let (src, _) = words(old); + for i in 0..keep { + attrs_write( + fresh, + i, + Word::decode(*src.add(i as usize)).entry, + key_at(keys_now, i), + ); + } + keys = keys_now; + attach_attrs(keys, fresh); + attrs = fresh; + } + if (*attrs).length > pos { + // Entries past `pos` belonged to keys the list no longer has. + (*attrs).length = pos; + } + for i in (*attrs).length..pos { + attrs_write(attrs, i, 0, key_at(keys, i)); + } + attrs_write(attrs, pos, entry, key_at(keys, pos)); + keys +} + +/// An OWNED list removed position `pos` by shifting its tail down one slot: +/// shift the entries with it. +/// +/// # Safety +/// `keys` is a live, exclusively owned keys array whose key shift is done. +pub(crate) unsafe fn owned_note_remove(keys: *mut ArrayHeader, pos: u32) { + let attrs = keys_attrs(keys); + if attrs.is_null() { + return; + } + let (w, len) = words(attrs); + if pos as usize >= len { + return; + } + // GC_STORE_AUDIT(POINTER_FREE): INT32 boxes moving inside a pointer-free array. + std::ptr::copy( + w.add(pos as usize + 1), + w.add(pos as usize), + len - pos as usize - 1, + ); + (*attrs).length = len as u32 - 1; + recompute_cumulative(keys, attrs, pos); +} + +/// An OWNED list's position `pos` became a hole: its entry is the default. +/// +/// # Safety +/// `keys` is a live, exclusively owned keys array. +pub(crate) unsafe fn owned_note_hole(keys: *mut ArrayHeader, pos: u32) { + let attrs = keys_attrs(keys); + if attrs.is_null() || pos >= (*attrs).length { + return; + } + attrs_set_owned(keys, attrs, pos, 0); +} + +/// An OWNED list was compacted in place: the entry of old position +/// `mapping[i]` now belongs to position `i`. `mapping` is increasing. +/// +/// # Safety +/// `keys` is a live, exclusively owned keys array. +pub(crate) unsafe fn owned_note_compaction(keys: *mut ArrayHeader, mapping: &[u32]) { + let attrs = keys_attrs(keys); + if attrs.is_null() { + return; + } + let (w, len) = words(attrs); + for (to, &from) in mapping.iter().enumerate() { + let entry = if (from as usize) < len { + Word::decode(*w.add(from as usize)).entry + } else { + 0 + }; + // GC_STORE_AUDIT(POINTER_FREE): INT32 boxes inside a pointer-free array. + *w.add(to) = Word { + entry, + ..Word::EMPTY + } + .encode(); + } + (*attrs).length = mapping.len() as u32; + recompute_cumulative(keys, attrs, 0); +} + +#[cfg(test)] +#[path = "key_attrs_tests.rs"] +mod key_attrs_tests; diff --git a/crates/perry-runtime/src/object/key_attrs_tests.rs b/crates/perry-runtime/src/object/key_attrs_tests.rs new file mode 100644 index 0000000000..af208e8086 --- /dev/null +++ b/crates/perry-runtime/src/object/key_attrs_tests.rs @@ -0,0 +1,199 @@ +//! Storage of key attributes beside the keys (`key_attrs.rs`). + +use super::*; + +unsafe fn list_with_entries(entries: &[u8]) -> *mut ArrayHeader { + let keys = alloc_key_list(entries.len() as u32, true, true); + let attrs = keys_attrs(keys); + assert!(!attrs.is_null(), "an attribute list carries its array"); + for (i, &e) in entries.iter().enumerate() { + let name = format!("ka{i}"); + let k = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let key = crate::JSValue::string_ptr(k); + let elems = crate::array::array_elements_ptr(keys); + *elems.add(i) = key.bits(); + (*keys).length = i as u32 + 1; + attrs_write(attrs, i as u32, e, key); + } + keys +} + +#[test] +fn default_entries_mean_default_attributes() { + assert_eq!( + attr_bits_to_entry(0x07), + 0, + "writable + enumerable + configurable is the default entry" + ); + assert_eq!(entry_to_attr_bits(0), 0x07); + assert_eq!( + entry_to_attr_bits(attr_bits_to_entry(0x02)), + 0x02, + "round trip" + ); + assert!(entry_is_plain_writable_data(0)); + assert!(entry_is_plain_writable_data(ENTRY_NON_ENUMERABLE)); + assert!(!entry_is_plain_writable_data(ENTRY_NON_WRITABLE)); + assert!(!entry_is_plain_writable_data( + ENTRY_ACCESSOR | ENTRY_HAS_GET + )); +} + +#[test] +fn an_accessor_is_not_a_non_writable_data_key() { + let s = entry_summary(ENTRY_ACCESSOR | ENTRY_NON_WRITABLE); + assert_eq!(s & SUMMARY_ACCESSOR, SUMMARY_ACCESSOR); + assert_eq!(s & SUMMARY_NON_WRITABLE, 0); +} + +/// The summary of a PREFIX is exact: a shape names a prefix of a shared +/// backing, and a key past its count must not leak into its summary. +#[test] +fn the_summary_of_a_prefix_ignores_later_positions() { + let _lock = crate::gc::global_side_table_test_lock(); + unsafe { + let keys = list_with_entries(&[0, ENTRY_NON_ENUMERABLE, 0, ENTRY_ACCESSOR | ENTRY_HAS_GET]); + assert_eq!(keys_summary(keys, 0), 0); + assert_eq!(keys_summary(keys, 1), 0, "first key is default"); + assert_eq!(keys_summary(keys, 2), SUMMARY_NON_ENUMERABLE); + assert_eq!(keys_summary(keys, 3), SUMMARY_NON_ENUMERABLE); + assert_eq!( + keys_summary(keys, 4), + SUMMARY_NON_ENUMERABLE | SUMMARY_ACCESSOR + ); + assert_eq!(keys_entry(keys, 1), ENTRY_NON_ENUMERABLE); + assert_eq!(keys_entry(keys, 9), 0, "past the array: default"); + assert!(!keys_have_entries(keys, 1)); + assert!(keys_have_entries(keys, 2)); + } +} + +/// An in-place edit of an owned list recomputes every later cumulative word, +/// so a cleared entry stops contributing. +#[test] +fn an_owned_edit_recomputes_the_summary() { + let _lock = crate::gc::global_side_table_test_lock(); + unsafe { + let keys = list_with_entries(&[ENTRY_NON_WRITABLE, 0, 0]); + assert_eq!(keys_summary(keys, 3), SUMMARY_NON_WRITABLE); + attrs_set_owned(keys, keys_attrs(keys), 0, 0); + assert_eq!( + keys_summary(keys, 3), + 0, + "the cleared entry must not linger" + ); + attrs_set_owned(keys, keys_attrs(keys), 2, ENTRY_NON_CONFIGURABLE); + assert_eq!(keys_summary(keys, 2), 0); + assert_eq!(keys_summary(keys, 3), SUMMARY_NON_CONFIGURABLE); + } +} + +/// A list without attributes answers default everywhere and is laid out +/// exactly as before (no front reserve). +#[test] +fn an_attribute_free_list_is_unchanged() { + let _lock = crate::gc::global_side_table_test_lock(); + unsafe { + let keys = alloc_key_list(4, false, false); + assert!(keys_attrs(keys).is_null()); + assert_eq!(crate::array::array_front_offset(keys), 0); + let with = alloc_key_list(4, false, true); + assert_eq!( + crate::array::array_front_offset(with), + KEYS_ATTRS_FRONT_SLOTS + ); + } +} + +/// The prefix filters: a key with no entry in the prefix is proved absent, +/// and a key past the prefix does not leak into it. Sabotage: dropping the +/// Bloom update in `Word::after` makes the first assertion fail. +#[test] +fn the_prefix_filters_prove_absence() { + let _lock = crate::gc::global_side_table_test_lock(); + unsafe { + let keys = list_with_entries(&[0, ENTRY_NON_ENUMERABLE, 0, ENTRY_ACCESSOR | ENTRY_HAS_GET]); + assert!( + keys_may_carry(keys, 4, b"ka1", false), + "ka1 carries an entry" + ); + assert!(keys_may_carry(keys, 4, b"ka3", true), "ka3 is an accessor"); + // Absence is only PROVED where the filter has no bit; a collision may + // answer "maybe", so assert the exact negatives on a single-entry prefix. + let single = list_with_entries(&[ENTRY_NON_WRITABLE]); + assert!(!keys_may_carry(single, 1, b"ka0", true), "not an accessor"); + assert!( + !keys_may_carry(keys, 1, b"ka1", false), + "ka1 is past the prefix" + ); + } +} + +fn key(name: &str) -> *mut crate::StringHeader { + crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32) +} + +/// A dictionary receiver edits its PRIVATE list in place, and a compacting +/// delete shifts the attributes with the keys. Sabotage: skipping the shift in +/// `owned_note_remove` leaves `k3`'s attributes at the old position, so `k4` +/// reads non-writable and `k3` writable. +#[test] +fn a_dictionary_delete_shifts_the_attributes_with_the_keys() { + let _lock = crate::gc::global_side_table_test_lock(); + unsafe { + let obj = crate::object::js_object_alloc(0, 8); + for i in 0..6 { + crate::object::js_object_set_field_by_name(obj, key(&format!("dk{i}")), i as f64); + } + assert!( + crate::object::dictionary::latch_object_to_dictionary(obj), + "premise: the receiver latches to dictionary mode" + ); + crate::object::set_property_attrs( + obj as usize, + "dk3".to_string(), + crate::object::PropertyAttrs::new(false, true, true), + ); + assert!( + crate::object::dictionary::is_dictionary(obj), + "premise: still a dictionary" + ); + crate::object::js_object_delete_field(obj, key("dk1")); + let attrs = |k: &str| crate::object::get_property_attrs(obj as usize, k); + assert!( + attrs("dk3").is_some_and(|a| !a.writable()), + "dk3 keeps its attributes across the compacting delete" + ); + assert!( + attrs("dk4").is_none(), + "dk4 must not inherit dk3's old position" + ); + assert!(attrs("dk2").is_none()); + } +} + +/// An attribute installed on a key the object does not have yet CLAIMS the +/// key, with its attributes: an attribute always has a key. Sabotage: skipping +/// the claim in `apply_edits` loses the attribute. +#[test] +fn an_attribute_on_an_absent_key_claims_the_key() { + let _lock = crate::gc::global_side_table_test_lock(); + unsafe { + let obj = crate::object::js_object_alloc(0, 2); + crate::object::js_object_set_field_by_name(obj, key("present"), 1.0); + crate::object::set_property_attrs( + obj as usize, + "claimed".to_string(), + crate::object::PropertyAttrs::new(false, false, true), + ); + let keys = crate::object::object_keys(obj); + let pos = crate::object::keys_find_slot_by_bytes(keys.arr(), keys.count(), b"claimed") + .expect("the attribute claimed its key"); + assert_eq!( + keys_entry(keys.arr(), pos), + ENTRY_NON_WRITABLE | ENTRY_NON_ENUMERABLE + ); + let attrs = crate::object::get_property_attrs(obj as usize, "claimed").unwrap(); + assert!(!attrs.writable() && !attrs.enumerable() && attrs.configurable()); + } +} diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 06b92f817c..861587127a 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -89,8 +89,11 @@ pub(crate) use class_registry::class_registry_census; #[cfg(feature = "regex-engine")] pub(crate) use class_registry::construct_two_rooted; pub(crate) use class_registry::{construct_rooted_arguments, scan_current_new_target_root_mut}; +#[cfg(feature = "attr-census")] +pub(crate) mod attr_census; pub(crate) mod canonical_keys; mod census; +pub(crate) mod key_attrs; pub(crate) use census::object_tables_census; #[cfg(test)] mod bound_method_receiver_tests; @@ -334,16 +337,16 @@ pub use descriptor_state::PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED; pub(crate) use descriptor_state::{ accessor_descriptor_keys_for_obj, class_field_inline_guard_enabled, class_instance_set_may_intercept, clear_accessor_descriptor, clear_property_attrs, - constructor_accessor_ever_installed, descriptors_in_use, disable_class_field_inline_guard, - get_accessor_descriptor, get_property_attrs, install_fresh_accessor_property, - json_object_getter_value, mark_all_keys, object_has_descriptors, - object_proto_may_intercept_key, own_descriptors_skip_key, owner_has_property_descriptors, - owner_may_have_descriptor_entries, plain_custom_prototype_may_intercept, - plain_data_write_may_intercept, prune_dead_descriptor_owner_entries, - prune_dead_descriptor_owner_entries_young, reflect_getter_closure_bits, - set_accessor_descriptor, set_builtin_accessor_descriptor, set_builtin_property_attrs, - set_property_attrs, transfer_descriptor_owner, AccessorDescriptor, DescriptorTables, - PropertyAttrs, + constructor_accessor_ever_installed, define_builtin_data_property, descriptors_in_use, + disable_class_field_inline_guard, get_accessor_descriptor, get_property_attrs, + install_fresh_accessor_property, json_object_getter_value, mark_all_keys, + object_has_descriptors, object_proto_may_intercept_key, own_descriptors_skip_key, + owner_has_property_descriptors, owner_may_have_descriptor_entries, + plain_custom_prototype_may_intercept, plain_data_write_may_intercept, + prune_dead_descriptor_owner_entries, prune_dead_descriptor_owner_entries_young, + reflect_getter_closure_bits, set_accessor_descriptor, set_builtin_accessor_descriptor, + set_builtin_property_attrs, set_property_attrs, transfer_descriptor_owner, AccessorDescriptor, + DescriptorTables, PropertyAttrs, }; pub(crate) use field_get_set::FieldLookupCaches; pub(crate) use field_get_set::{ @@ -1749,7 +1752,9 @@ const _: () = assert!(std::mem::offset_of!(ObjectHeader, meta) == 8); const _: () = assert!(std::mem::size_of::() == 8); pub(crate) mod cell_meta; -pub(crate) use cell_meta::cell_meta_slot; +pub(crate) use cell_meta::{ + cell_expando_ensure, cell_expando_get, cell_meta_slot, cell_meta_slot_for_header, +}; // `cell_has_meta_edge` is `#[cfg(test)]` in `cell_meta`, so its re-export // must be too or the import is unresolved in a non-test build. #[cfg(test)] @@ -1936,63 +1941,3 @@ mod transition_ic_tests; mod wide_field_read_tests; #[cfg(test)] mod wide_object_membership_tests; - -/// The named-property bag for a cell that has no inline slot layout of its own, -/// creating it on first write. -/// -/// #6759 phase 1. An `ErrorHeader` (and the other exotic cells) cannot hold -/// named properties inline, so they lived in tables keyed by the owner's -/// ADDRESS — `ERROR_USER_PROPS` and friends — which cost four GC hooks -/// (rekey-on-evacuation, finalize, dead-sweep, root scanner) and carried a -/// standing hazard: a recycled address inherits the previous tenant's -/// properties. -/// -/// The bag is an ordinary object hanging off `ObjectMeta.expando`, so it is an -/// ordinary child edge — it moves with its owner, dies with its owner, and -/// keeps ECMA-262 insertion order for free because that is what an object's -/// `keys_array` already does. -pub(crate) unsafe fn cell_expando_ensure(user_ptr: usize) -> Option<*mut ObjectHeader> { - let meta = object_meta_ensure_for_cell(user_ptr)?; - if (*meta).expando != 0 { - return Some( - crate::value::JSValue::from_bits((*meta).expando).as_pointer::() - as *mut ObjectHeader, - ); - } - // `js_object_alloc` allocates and can move the owner, so re-resolve the - // meta record from the rooted address afterwards. - let scope = crate::gc::RuntimeHandleScope::new(); - let owner = scope.root_raw_mut_ptr(user_ptr as *mut u8); - let bag = js_object_alloc(0, 0); - let user_ptr = owner.get_raw_mut_ptr::() as usize; - let meta = object_meta_ensure_for_cell(user_ptr)?; - if (*meta).expando != 0 { - return Some( - crate::value::JSValue::from_bits((*meta).expando).as_pointer::() - as *mut ObjectHeader, - ); - } - let boxed = crate::value::js_nanbox_pointer(bag as i64).to_bits(); - // GC_STORE_AUDIT(BARRIERED): metadata-record slot store + object barrier. - (*meta).expando = boxed; - crate::gc::runtime_write_barrier_slot( - meta as usize, - &(*meta).expando as *const _ as usize, - boxed, - ); - Some(bag) -} - -/// The existing bag, or `None` when the owner never took one. Never allocates, -/// so it is safe on read paths. -pub(crate) unsafe fn cell_expando_get(user_ptr: usize) -> Option<*mut ObjectHeader> { - let slot = cell_meta_slot(user_ptr)?; - let meta = *slot; - if meta.is_null() || (*meta).expando == 0 { - return None; - } - Some( - crate::value::JSValue::from_bits((*meta).expando).as_pointer::() - as *mut ObjectHeader, - ) -} diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index dc69b1824a..5cb3bc1734 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -674,9 +674,10 @@ pub(crate) fn install_global_webcrypto(singleton: *mut ObjectHeader) { pub(crate) fn install_webcrypto_constructor_proto(proto_obj: *mut ObjectHeader, ctor_value: f64) { let constructor = "constructor"; let key = crate::string::js_string_from_bytes(constructor.as_ptr(), constructor.len() as u32); - js_object_set_field_by_name(proto_obj, key, ctor_value); - super::set_builtin_property_attrs( - proto_obj as usize, + super::define_builtin_data_property( + proto_obj, + key, + ctor_value, constructor.to_string(), super::PropertyAttrs::new(true, false, true), ); diff --git a/crates/perry-runtime/src/object/native_module/callable_exports.rs b/crates/perry-runtime/src/object/native_module/callable_exports.rs index 88ae63acc2..51075e1182 100644 --- a/crates/perry-runtime/src/object/native_module/callable_exports.rs +++ b/crates/perry-runtime/src/object/native_module/callable_exports.rs @@ -726,9 +726,10 @@ fn attach_assert_prototype(constructor_value: f64) { let constructor = "constructor"; let constructor_key = crate::string::js_string_from_bytes(constructor.as_ptr(), constructor.len() as u32); - js_object_set_field_by_name(proto, constructor_key, constructor_value); - super::set_builtin_property_attrs( - proto as usize, + super::define_builtin_data_property( + proto, + constructor_key, + constructor_value, constructor.to_string(), super::PropertyAttrs::new(true, false, true), ); @@ -736,9 +737,10 @@ fn attach_assert_prototype(constructor_value: f64) { for method in ASSERT_PROTOTYPE_METHODS { let method_value = bound_native_callable_export_value("assert", method); let key = crate::string::js_string_from_bytes(method.as_ptr(), method.len() as u32); - js_object_set_field_by_name(proto, key, method_value); - super::set_builtin_property_attrs( - proto as usize, + super::define_builtin_data_property( + proto, + key, + method_value, (*method).to_string(), super::PropertyAttrs::new(true, false, true), ); @@ -792,9 +794,10 @@ fn attach_sqlite_database_sync_prototype(constructor_value: f64) { let constructor = "constructor"; let constructor_key = crate::string::js_string_from_bytes(constructor.as_ptr(), constructor.len() as u32); - js_object_set_field_by_name(proto, constructor_key, constructor_value); - super::set_builtin_property_attrs( - proto as usize, + super::define_builtin_data_property( + proto, + constructor_key, + constructor_value, constructor.to_string(), super::PropertyAttrs::new(true, false, true), ); @@ -813,9 +816,10 @@ fn attach_sqlite_database_sync_prototype(constructor_value: f64) { set_builtin_closure_length(method_closure as usize, 0); let key = crate::string::js_string_from_bytes(method.as_ptr(), method.len() as u32); let method_value = crate::value::js_nanbox_pointer(method_closure as i64); - js_object_set_field_by_name(proto, key, method_value); - super::set_builtin_property_attrs( - proto as usize, + super::define_builtin_data_property( + proto, + key, + method_value, (*method).to_string(), super::PropertyAttrs::new(true, false, true), ); @@ -859,9 +863,10 @@ fn attach_sqlite_session_prototype(constructor_value: f64) { set_builtin_closure_length(method_closure as usize, 0); let key = crate::string::js_string_from_bytes(method.as_ptr(), method.len() as u32); let method_value = crate::value::js_nanbox_pointer(method_closure as i64); - js_object_set_field_by_name(proto, key, method_value); - super::set_builtin_property_attrs( - proto as usize, + super::define_builtin_data_property( + proto, + key, + method_value, (*method).to_string(), super::PropertyAttrs::new(true, true, true), ); @@ -896,9 +901,10 @@ fn attach_sqlite_session_prototype(constructor_value: f64) { let constructor = "constructor"; let constructor_key = crate::string::js_string_from_bytes(constructor.as_ptr(), constructor.len() as u32); - js_object_set_field_by_name(proto, constructor_key, constructor_value); - super::set_builtin_property_attrs( - proto as usize, + super::define_builtin_data_property( + proto, + constructor_key, + constructor_value, constructor.to_string(), super::PropertyAttrs::new(true, false, true), ); @@ -1079,9 +1085,10 @@ fn attach_crypto_key_object_shape(closure_addr: usize, constructor_value: f64) { let constructor = "constructor"; let constructor_key = crate::string::js_string_from_bytes(constructor.as_ptr(), constructor.len() as u32); - js_object_set_field_by_name(proto, constructor_key, constructor_value); - super::set_builtin_property_attrs( - proto as usize, + super::define_builtin_data_property( + proto, + constructor_key, + constructor_value, constructor.to_string(), super::PropertyAttrs::new(true, false, true), ); @@ -1109,9 +1116,10 @@ fn attach_crypto_x509_certificate_shape(closure_addr: usize, constructor_value: let constructor = "constructor"; let constructor_key = crate::string::js_string_from_bytes(constructor.as_ptr(), constructor.len() as u32); - js_object_set_field_by_name(proto, constructor_key, constructor_value); - super::set_builtin_property_attrs( - proto as usize, + super::define_builtin_data_property( + proto, + constructor_key, + constructor_value, constructor.to_string(), super::PropertyAttrs::new(true, false, true), ); diff --git a/crates/perry-runtime/src/object/object_ops.rs b/crates/perry-runtime/src/object/object_ops.rs index 52bb41e014..96961ad629 100644 --- a/crates/perry-runtime/src/object/object_ops.rs +++ b/crates/perry-runtime/src/object/object_ops.rs @@ -50,8 +50,9 @@ pub(crate) use descriptor_helpers::{ // re-exported, so `crate::object::value_is_callable` resolves uniquely to the // `instanceof.rs` definition (preserves the pre-split resolution). pub(crate) use keys_array::{ - ensure_key_in_keys_array, ensure_key_in_keys_array_for_value, install_builtin_getter, - own_key_present, own_key_present_via_index, + ensure_key_in_keys_array, ensure_key_in_keys_array_for_value, + ensure_key_in_keys_array_with_entry, install_builtin_getter, own_key_present, + own_key_present_via_index, }; /// Helper: extract object pointer from NaN-boxed f64. Returns null on failure. diff --git a/crates/perry-runtime/src/object/object_ops/define_get_accessor.rs b/crates/perry-runtime/src/object/object_ops/define_get_accessor.rs index 1ee51a821d..3e01a8b8f6 100644 --- a/crates/perry-runtime/src/object/object_ops/define_get_accessor.rs +++ b/crates/perry-runtime/src/object/object_ops/define_get_accessor.rs @@ -156,13 +156,20 @@ unsafe fn try_fast_install( // ---- Committed: mirror the generic ordinary-object accessor arm. ---- super::super::mark_object_dynamic_shape_unknown(obj); // Make the key discoverable (hasOwn / keys / getOwnPropertyNames) — the - // accessor itself lives in the side table, not in a value slot. The entry - // point roots both arguments before its first possible allocation. + // accessor itself lives in the side table, not in a value slot. The key + // is claimed WITH the attributes installed below (charter step 3), so an + // `exports` object gaining one getter per re-export appends each one in + // place instead of copying its key list per getter. The entry point roots + // both arguments before its first possible allocation. + let has_getter = + !crate::JSValue::from_bits(getter_handle.get_nanbox_f64().to_bits()).is_undefined(); + let entry = crate::object::key_attrs::AttrsEdit::Data(&[], FAST_ARM_ATTRS.bits) + .apply(crate::object::key_attrs::AttrsEdit::Accessor(&[], has_getter, false).apply(0)); if let Some(key_str) = refreshed_key_str { - ensure_key_in_keys_array(obj, key_str); + ensure_key_in_keys_array_with_entry(obj, key_str, entry); } else { key_str_handle.with_const_ptr(|key_str: *const crate::StringHeader| { - ensure_key_in_keys_array(obj, key_str) + ensure_key_in_keys_array_with_entry(obj, key_str, entry) }); } obj_value = f64::from_bits(obj_handle.get_heap_word_u64()); @@ -204,11 +211,16 @@ unsafe fn try_fast_install( get: get_bits, set: 0, }, - PropertyAttrs::new(true, true, false), + FAST_ARM_ATTRS, ); Some(f64::from_bits(obj_handle.get_heap_word_u64())) } +/// The attributes of the fast arm's brand-new `{ get, enumerable: true }` +/// accessor: `enumerable` explicit, omitted `configurable` false, and the +/// internal writable bit true (see the install site). +const FAST_ARM_ATTRS: PropertyAttrs = PropertyAttrs::new(true, true, false); + /// `Object.defineProperty(obj, key, { get: getter, enumerable: true })`, /// without the descriptor allocation on the admissible path. Returns the /// object (NaN-boxed), like `js_object_define_property`. diff --git a/crates/perry-runtime/src/object/object_ops/keys_array.rs b/crates/perry-runtime/src/object/object_ops/keys_array.rs index 585159e3c6..b5bbb34948 100644 --- a/crates/perry-runtime/src/object/object_ops/keys_array.rs +++ b/crates/perry-runtime/src/object/object_ops/keys_array.rs @@ -15,7 +15,28 @@ pub(crate) unsafe fn ensure_key_in_keys_array( obj: *mut ObjectHeader, key: *const crate::StringHeader, ) { - ensure_key_in_keys_array_inner(obj, key, false) + ensure_key_in_keys_array_inner(obj, key, false, 0) +} + +/// Claim a keys slot for `key` together with its ATTRIBUTES +/// (`key_attrs.rs` entry, nonzero): an accessor install, a literal +/// `get`/`set`, `defineProperty` of a new key. One trie edge +/// (`canonical_keys::extend_key_with_entry`) — on the tip of an attribute +/// backing an in-place append, where adding the key and then changing its +/// attributes would copy the list once per key. The learned transition cache +/// is a default-attribute lattice, so this claim neither consults nor +/// teaches it. +/// +/// # Safety +/// As [`ensure_key_in_keys_array`]. +pub(crate) unsafe fn ensure_key_in_keys_array_with_entry( + obj: *mut ObjectHeader, + key: *const crate::StringHeader, + entry: u8, +) { + #[cfg(feature = "attr-census")] + crate::object::attr_census::note_global("claim.with_entry"); + ensure_key_in_keys_array_inner(obj, key, false, entry) } /// [`ensure_key_in_keys_array`] for a claim the caller will follow with a value @@ -43,7 +64,7 @@ pub(crate) unsafe fn ensure_key_in_keys_array_for_value( obj: *mut ObjectHeader, key: *const crate::StringHeader, ) { - ensure_key_in_keys_array_inner(obj, key, true) + ensure_key_in_keys_array_inner(obj, key, true, 0) } /// `refresh_define_property_roots!` re-reads BOTH roots at every allocation @@ -55,6 +76,7 @@ unsafe fn ensure_key_in_keys_array_inner( obj: *mut ObjectHeader, key: *const crate::StringHeader, writes_value: bool, + entry: u8, ) { if obj.is_null() || (obj as usize) < 0x10000 || key.is_null() { return; @@ -93,7 +115,8 @@ unsafe fn ensure_key_in_keys_array_inner( // object has any key at all), and the `[[Set]]` tail already // learns these keyless→one-key edges. Try the shared edge before // minting a private array, and teach it otherwise. - let transition_eligible_first = define_append_transition_eligible(obj, keys.arr()); + let transition_eligible_first = + entry == 0 && define_append_transition_eligible(obj, keys.arr()); let prev_shape_id = if transition_eligible_first { super::super::shapes::object_shape_stamp(obj) } else { @@ -134,6 +157,24 @@ unsafe fn ensure_key_in_keys_array_inner( } } } + if entry != 0 { + // A first key WITH attributes: its canonical one-key list. + // (A keyless receiver is never a dictionary.) + if let Some(proof) = crate::object::canonical_keys::SharedLayout::of_receiver(obj) { + let list = crate::object::canonical_keys::extend_key_with_entry( + &proof, + crate::object::canonical_keys::CanonicalKeys::EMPTY, + key, + entry, + ); + refresh_define_property_roots!(); + set_object_keys(obj, list.view()); + if crate::object::object_live_slot_count(obj) == 0 { + set_object_live_slot_count(obj, 1); + } + } + return; + } let new_keys = crate::array::js_array_alloc(4); refresh_define_property_roots!(); let new_keys = @@ -226,7 +267,7 @@ unsafe fn ensure_key_in_keys_array_inner( // separately by the caller's `set_property_attrs`, whose keyed semantic // transition gives every receiver performing the same install the same // successor shape. - let transition_eligible = define_append_transition_eligible(obj, keys.arr()); + let transition_eligible = entry == 0 && define_append_transition_eligible(obj, keys.arr()); let mut interned_handle = None; let mut prev_shape_id = 0u32; if transition_eligible { @@ -293,13 +334,21 @@ unsafe fn ensure_key_in_keys_array_inner( let canonical_parent = crate::object::canonical_keys::canonicalize(&proof, keys.arr(), key_count as u32); refresh_define_property_roots!(); - crate::object::canonical_keys::extend_key(&proof, canonical_parent, key).view() + crate::object::canonical_keys::extend_key_with_entry( + &proof, + canonical_parent, + key, + entry, + ) + .view() } None => { - // A dictionary receiver's list is its own: it grows in place. + // A dictionary receiver's list is its own: it grows in place, and + // its attributes with it. let owned = scope.root_raw_mut_ptr(keys.arr()); let grown = crate::array::js_array_push(keys.arr(), JSValue::string_ptr(key as *mut _)); let _ = owned.get_raw_mut_ptr::(); + let grown = crate::object::key_attrs::owned_note_append(grown, key_count as u32, entry); crate::object::ObjectKeys::owned(grown) } }; @@ -624,8 +673,15 @@ pub(crate) unsafe fn install_builtin_getter(proto: *mut ObjectHeader, key: &str, if key_str.is_null() { return; } - // Make the key discoverable by `own_key_present` / `getOwnPropertyNames`. - ensure_key_in_keys_array(proto, key_str); + // Make the key discoverable by `own_key_present` / `getOwnPropertyNames`, + // claimed WITH the accessor's attributes (charter step 3): the key arrives + // as one edge of the prototype's list instead of arriving default and being + // rewritten. Mirrors `set_builtin_accessor_descriptor` below, which then + // finds the entry already in place. + let entry = crate::object::key_attrs::AttrsEdit::Data(&[], BUILTIN_GETTER_ATTRS.bits).apply( + crate::object::key_attrs::AttrsEdit::Accessor(&[], getter_bits != 0, false).apply(0), + ); + ensure_key_in_keys_array_with_entry(proto, key_str, entry); // Spec: an accessor getter's `.name` is `"get " + key` (e.g. // `Object.getOwnPropertyDescriptor(ArrayBuffer.prototype,"byteLength").get.name // === "get byteLength"`). Register it against the getter closure's func_ptr; @@ -642,11 +698,14 @@ pub(crate) unsafe fn install_builtin_getter(proto: *mut ObjectHeader, key: &str, get: getter_bits, set: 0, }, - // writable is N/A for an accessor; enumerable=false, configurable=true. - PropertyAttrs::new(true, false, true), + BUILTIN_GETTER_ATTRS, ); } +/// A builtin getter's attributes: writable is N/A for an accessor; +/// enumerable=false, configurable=true. +const BUILTIN_GETTER_ATTRS: PropertyAttrs = PropertyAttrs::new(true, false, true); + /// O(1) own-key presence via the [[Set]]-path sidecar, for the /// `Object.defineProperty` flow (#6743). Returns `Some(present)` when the /// sidecar is applicable — a genuine wide object (keys past diff --git a/crates/perry-runtime/src/object/reserved_floor.rs b/crates/perry-runtime/src/object/reserved_floor.rs index 84bf120a0f..e0c95a12db 100644 --- a/crates/perry-runtime/src/object/reserved_floor.rs +++ b/crates/perry-runtime/src/object/reserved_floor.rs @@ -143,7 +143,14 @@ unsafe fn stamp_reserved_floor_shape( }; crate::array::clear_array_subclass_named_prefix_token(obj); let id = shapes::publish_shape_result(shapes::shape_descriptor_ensure_with_holes( - keys, floor, live, generation, kind, floor, proto_id, + keys, + floor, + live, + generation, + kind, + floor, + proto_id, + shapes::receiver_extra_summary(obj), )); shapes::stamp_object_shape_id_with_carrier_note(obj, id); shapes::debug_assert_object_shape_parity_for_keys( diff --git a/crates/perry-runtime/src/object/shapes.rs b/crates/perry-runtime/src/object/shapes.rs index 2e1c8ddfd2..0312fb05f5 100644 --- a/crates/perry-runtime/src/object/shapes.rs +++ b/crates/perry-runtime/src/object/shapes.rs @@ -135,6 +135,10 @@ pub(crate) struct ShapeDescriptor { /// updates this count in place while per-slot IC validation protects the /// stable id (#9064). pub(crate) hole_count: u32, + /// Charter step 3: the attribute summary (`key_attrs::SUMMARY_*`) of the + /// keys this shape names — what may be an accessor, non-writable, + /// non-enumerable or non-configurable. Zero for an all-default shape. + pub(crate) summary: u8, } /// Shape identity is the FACTS, never the storage address. A descriptor value @@ -193,6 +197,14 @@ impl ShapeRecordRef { unsafe { (*self.0.as_ptr()).live_inline_slot_count } } + /// The record's attribute summary (`key_attrs::SUMMARY_*`): one load, + /// asked before any per-key attribute lookup. + #[inline] + pub(crate) fn summary(self) -> u8 { + // SAFETY: a live slab record (type docs). + unsafe { (*self.0.as_ptr()).summary() } + } + /// The record's current `keys` word (0 for a keyless shape). #[inline] pub(crate) fn keys(self) -> u64 { @@ -218,6 +230,7 @@ impl PartialEq for ShapeDescriptor { && self.proto_id == other.proto_id && self.object_kind == other.object_kind && self.hole_count == other.hole_count + && self.summary == other.summary } } @@ -700,6 +713,7 @@ fn shape_descriptor_ensure_with_generation( semantic_generation: u64, object_kind: ShapeObjectKind, proto_id: u64, + extra_summary: u8, ) -> Result { shape_descriptor_ensure_with_holes( keys, @@ -709,6 +723,7 @@ fn shape_descriptor_ensure_with_generation( object_kind, 0, proto_id, + extra_summary, ) } @@ -717,6 +732,12 @@ fn shape_descriptor_ensure_with_generation( /// identity distinct from every hole state of the same array. Also the mint /// for #9019's reserved-floor seed (`object/reserved_floor.rs`), whose keys /// array is BORN with `floor` leading holes. +/// +/// `extra_summary` is attribute summary the keys do not carry themselves: a +/// dictionary receiver's private list ([`receiver_extra_summary`]). The +/// summary of the published keys is derived here, from the keys, so no +/// caller can publish a shape that under-reports its attributes. +#[allow(clippy::too_many_arguments)] #[cfg_attr(feature = "shape-mint-diag", track_caller)] pub(crate) fn shape_descriptor_ensure_with_holes( keys: *const ArrayHeader, @@ -726,11 +747,21 @@ pub(crate) fn shape_descriptor_ensure_with_holes( object_kind: ShapeObjectKind, hole_count: u32, proto_id: u64, + extra_summary: u8, ) -> Result { let keys_id = keys as usize as u64; if keys_id == 0 && logical_key_count != 0 { return Err(ShapeDescriptorError::InvalidFacts); } + // SAFETY: a live keys array or 0 (`keys_attrs` resolves through the + // ownership-checking array resolver, so a test's synthetic address + // reads as attribute-free). + let summary = extra_summary + | if keys_id == 0 { + 0 + } else { + unsafe { crate::object::key_attrs::keys_summary_checked(keys, logical_key_count) } + }; // #10868 attribution, compiled out entirely without `shape-mint-diag`. // When it IS compiled in, both halves are gated on one relaxed atomic // load, and the key-list hash is resolved BEFORE the table borrow because @@ -762,6 +793,7 @@ pub(crate) fn shape_descriptor_ensure_with_holes( object_kind, hole_count, proto_id, + summary, ); let table = &crate::state::state().shapes; let mut inner = table.inner.borrow_mut(); @@ -783,6 +815,7 @@ pub(crate) fn shape_descriptor_ensure_with_holes( object_kind, hole_count, proto_id, + summary, ) { #[cfg(feature = "shape-mint-diag")] @@ -834,7 +867,8 @@ pub(crate) fn shape_descriptor_ensure_with_holes( object_kind, hole_count, ) - .with_proto_id(proto_id); + .with_proto_id(proto_id) + .with_summary(summary); // Publish by-id first, then the reverse accelerators. An ObjectHeader is // stamped only after this function returns, so a visible id always has a // complete descriptor. @@ -862,6 +896,7 @@ pub(crate) fn shape_descriptor_ensure( 0, ShapeObjectKind::Ordinary, PROTO_ID_DEFAULT, + 0, ) } @@ -885,9 +920,27 @@ pub(crate) unsafe fn shape_descriptor_ensure_for_object( 0, ShapeObjectKind::Ordinary, object_proto_id(obj), + receiver_extra_summary(obj), ) } +/// Attribute summary `obj`'s shape must carry beyond what its published keys +/// report. A DICTIONARY receiver publishes no keys (`object/dictionary.rs`), +/// so the attributes of its private list are summarized here — +/// conservatively, every per-key bit, whenever that list carries any: the +/// list is edited in place and a dictionary shape is never shared, so an +/// exact summary would buy nothing a per-key lookup does not. +/// +/// # Safety +/// `obj` is null or a live `ObjectHeader`. +#[inline] +pub(crate) unsafe fn receiver_extra_summary(obj: *const crate::object::ObjectHeader) -> u8 { + if obj.is_null() || !crate::object::dictionary::is_dictionary(obj) { + return 0; + } + crate::object::dictionary::private_list_summary(obj) +} + #[cold] #[inline(never)] fn shape_id_exhausted_abort() -> ! { @@ -940,6 +993,7 @@ pub(crate) fn shape_id_for_class_keys_ensure( 0, ShapeObjectKind::Ordinary, class_proto_id(class_id), + 0, )) } @@ -1199,6 +1253,7 @@ pub extern "C" fn js_object_shape_id_for_class_keys_live( 0, ShapeObjectKind::Ordinary, class_proto_id(class_id), + 0, )); // SAFETY: `id` was resolved from this agent's live slab record above. unsafe { note_external_shape_carrier(shape_descriptor_by_id(id)) }; @@ -1648,6 +1703,7 @@ pub(crate) unsafe fn stamp_object_shape( // (see the lineage publish below for the churn-growth rationale). lineage.hole_count, lineage.proto_id, + receiver_extra_summary(obj), )); if id != (*obj).parent_class_id { // Read-side lookup_ways also calls `stamp_object_shape` to populate its @@ -1964,6 +2020,7 @@ pub(crate) unsafe fn publish_object_shape_from( object_kind, hole_count, proto_id, + receiver_extra_summary(obj), )); stamp_object_shape_id_with_carrier_note(obj, id); if retire_owned_history { @@ -2014,6 +2071,60 @@ fn retire_owned_shape_siblings(keys: u64, keep: u32) { } } +/// RULE 1 for an accessor whose FUNCTION was replaced while its attributes +/// did not change (`Object.defineProperty(o, k, { get: other })` over an +/// accessor `k`). The attributes live with the keys and are unchanged, so +/// the key list — and with it every other identity fact — is the same; but +/// the getter/setter lives with the receiver, not the shape, and a cache +/// keyed on the ShapeId may have captured the old one. The successor's +/// generation is a pure function of (predecessor ShapeId, key), so receivers +/// replacing the same accessor from the same predecessor keep sharing a +/// shape (#10287: zod replaces a lazily installed accessor per schema). +/// +/// # Safety +/// `obj` is a live `ObjectHeader`, or null. +#[cfg_attr(feature = "shape-mint-diag", track_caller)] +pub(crate) unsafe fn transition_object_shape_accessor_replaced( + obj: *mut crate::object::ObjectHeader, + key_bytes: &[u8], +) -> u32 { + if obj.is_null() || !shape_word_is_writable(obj) { + return 0; + } + if crate::object::dictionary::is_dictionary(obj) { + return transition_object_shape_semantics(obj); + } + let prev = object_shape_stamp(obj); + let Some(current) = object_shape_descriptor(obj) else { + return transition_object_shape_semantics(obj); + }; + crate::array::clear_array_subclass_named_prefix_token(obj); + let key_hash = crate::object::key_bytes_hash(key_bytes.as_ptr(), key_bytes.len()); + // SplitMix64 over (predecessor, key, a tag no other transition uses). + // Bit 63 keeps it disjoint from the counter namespace (which aborts far + // below 2^62) and from the dictionary namespace (bit 62 alone). + let mut x = key_hash ^ (u64::from(prev) << 32 | u64::from(prev)) ^ 0xACCE_5500_0000_0000; + x ^= x >> 30; + x = x.wrapping_mul(0xbf58_476d_1ce4_e5b9); + x ^= x >> 27; + x = x.wrapping_mul(0x94d0_49bb_1331_11eb); + x ^= x >> 31; + let generation = x | (1 << 63); + let id = publish_shape_result(shape_descriptor_ensure_with_holes( + current.keys as usize as *mut ArrayHeader, + current.logical_key_count, + current.live_inline_slot_count, + generation, + current.object_kind, + current.hole_count, + current.proto_id, + receiver_extra_summary(obj), + )); + stamp_object_shape_id_with_carrier_note(obj, id); + debug_assert_object_shape_parity(obj); + id +} + /// Mint an exact successor for a descriptor/prototype semantic transition. /// The structural facts remain unchanged, but the process-unique generation /// prevents a cache trained before the transition from comparing equal after @@ -2049,67 +2160,13 @@ pub(crate) unsafe fn transition_object_shape_semantics( generation, current.object_kind, current.proto_id, + receiver_extra_summary(obj), )); stamp_object_shape_id_with_carrier_note(obj, id); debug_assert_object_shape_parity(obj); id } -/// #10287: a DATA-descriptor install reuses one generation per -/// `(predecessor facts, key, attributes)`, so two receivers built the same way -/// keep sharing shapes — and therefore transition edges, keys arrays and every -/// shape-keyed cache — instead of each getting a private lineage. -/// -/// Soundness rests on the same invariant the unique counter provides: a shape's -/// identity must imply its descriptor semantics. Every semantic event -/// (descriptor install, clear, accessor install, prototype change) mints a new -/// generation, so two receivers can only reach the same generation by applying -/// the same event to the same predecessor facts — which makes their descriptor -/// state identical by induction. Accessor installs keep minting unique -/// generations: their getter/setter identities differ per receiver, and nothing -/// in the shape records which closure a key resolves to. -/// Semantic generation for a descriptor transition, as a PURE function of the -/// transition itself: the predecessor shape, the key, and what is being -/// installed or removed. Two receivers that perform the same descriptor -/// operation over the same predecessor therefore land on the SAME successor -/// shape, which is what lets them keep sharing a transition chain. -/// -/// This replaced a per-thread memo table (#10287). The table was correct but -/// capacity-bound: it cleared wholesale at 8192 live entries, and a real zod -/// workload cleared it seven times, re-minting ~57k generations that had -/// already been agreed on and re-forking every receiver that depended on them. -/// A pure mix has no capacity, so an agreement reached once holds for the life -/// of the process. -/// -/// Bit 63 is set so these can never alias a counter-allocated generation from -/// [`transition_object_shape_semantics`] (that counter starts at 1 and aborts -/// long before it could reach 2^63). Distinct transitions collide only on a -/// full 64-bit hash collision, and a collision is only observable at all when -/// the structural facts (keys array, key count, live slots, kind) are also -/// identical. -fn deterministic_semantic_generation( - prev_shape_id: u32, - key_bytes: &[u8], - attrs: u8, -) -> Option { - if prev_shape_id == 0 { - // No predecessor identity to key on: keep the unique generation. - return None; - } - let key_hash = crate::object::key_bytes_hash(key_bytes.as_ptr(), key_bytes.len()); - // SplitMix64 finalizer over the three components, so nearby shape ids and - // one-byte key differences land far apart. - let mut x = key_hash - ^ (u64::from(prev_shape_id) << 32 | u64::from(prev_shape_id)) - ^ (u64::from(attrs) << 24); - x ^= x >> 30; - x = x.wrapping_mul(0xbf58_476d_1ce4_e5b9); - x ^= x >> 27; - x = x.wrapping_mul(0x94d0_49bb_1331_11eb); - x ^= x >> 31; - Some(x | (1 << 63)) -} - /// [`transition_object_shape_semantics`] for a PROTOTYPE divergence whose /// prototype has a stable serial. Falls back to the unique-generation /// transition, which is always correct, when there is no predecessor. @@ -2155,6 +2212,7 @@ pub(crate) unsafe fn transition_object_shape_prototype( current.object_kind, current.hole_count, proto_id, + receiver_extra_summary(obj), )); stamp_object_shape_id_with_carrier_note(obj, id); debug_assert_object_shape_parity(obj); @@ -2324,63 +2382,6 @@ pub(crate) fn shape_proto_id(id: u32) -> Option { Some(unsafe { (*record).proto_id }) } -/// [`transition_object_shape_semantics`] for a DATA-descriptor install, whose -/// successor is shared by every receiver that performs the same install over -/// the same predecessor facts (#10287). -/// [`transition_object_shape_semantics`] for a descriptor REMOVAL. A removal is -/// as repeatable as an install — every receiver that drops the same key from -/// the same predecessor reaches the same descriptor state — so it earns a -/// shared successor for the same reason (#10287). `attrs` is a tag here, not a -/// descriptor: `0xFE` for an attribute entry, `0xFF` for an accessor entry, so -/// a removal can never alias an install of the same key. -pub(crate) unsafe fn transition_object_shape_semantics_for_descriptor_removal( - obj: *mut crate::object::ObjectHeader, - key_bytes: &[u8], - accessor: bool, -) -> u32 { - let tag = if accessor { 0xFFu8 } else { 0xFEu8 }; - transition_object_shape_semantics_for_data_descriptor(obj, key_bytes, tag) -} - -#[cfg_attr(feature = "shape-mint-diag", track_caller)] -pub(crate) unsafe fn transition_object_shape_semantics_for_data_descriptor( - obj: *mut crate::object::ObjectHeader, - key_bytes: &[u8], - attrs: u8, -) -> u32 { - if obj.is_null() || !shape_word_is_writable(obj) { - return 0; - } - crate::array::clear_array_subclass_named_prefix_token(obj); - let current = object_shape_descriptor(obj).unwrap_or_else(|| { - synchronize_object_shape_descriptor(obj); - object_shape_descriptor(obj).expect("shape synchronization must publish a descriptor") - }); - // As for a prototype divergence: a dictionary receiver's shape is its - // own, and its generation stays in the dictionary namespace. - if crate::object::dictionary::is_dictionary(obj) { - return transition_object_shape_semantics(obj); - } - let Some(generation) = - deterministic_semantic_generation(object_shape_stamp(obj), key_bytes, attrs) - else { - // Table unavailable (teardown) or the counter wrapped: fall back to the - // unique-generation transition, which is always correct. - return transition_object_shape_semantics(obj); - }; - let id = publish_shape_result(shape_descriptor_ensure_with_generation( - current.keys as usize as *mut ArrayHeader, - current.logical_key_count, - current.live_inline_slot_count, - generation, - current.object_kind, - current.proto_id, - )); - stamp_object_shape_id_with_carrier_note(obj, id); - debug_assert_object_shape_parity(obj); - id -} - /// Turn a class-expression object into a class receiver. The kind is part of /// the exact immutable descriptor, so it cannot alias GC layout bits and every /// pre-mark ShapeId guard permanently misses afterward. @@ -2413,6 +2414,7 @@ pub(crate) unsafe fn transition_object_shape_to_class( current.semantic_generation, ShapeObjectKind::Class, current.proto_id, + receiver_extra_summary(obj), )); stamp_object_shape_id_with_carrier_note(obj, id); debug_assert_object_shape_parity(obj); diff --git a/crates/perry-runtime/src/object/shapes_slot_list.rs b/crates/perry-runtime/src/object/shapes_slot_list.rs index e813851c8b..f90ab264d6 100644 --- a/crates/perry-runtime/src/object/shapes_slot_list.rs +++ b/crates/perry-runtime/src/object/shapes_slot_list.rs @@ -490,6 +490,16 @@ pub(crate) unsafe fn try_update_stable_tombstone_shape( if current.keys != keys as u64 || current.object_kind() != super::ShapeObjectKind::Ordinary { return None; } + // Only a PRIVATE list's epoch may be updated in place. A receiver that + // entered stable-tombstone mode and was later moved onto a shared, + // canonical list (an attribute install rebuilds its keys canonically) + // still carries the object flag, but the record it names is a shared + // layout: a tip append on that backing keeps the address and must mint, + // or every carrier of the record would see this receiver's count — and + // none would see the appended key's attributes in the summary. + if keys_array_is_shape_shared(keys) { + return None; + } if current.logical_key_count == logical_key_count && current.live_inline_slot_count == live_inline_slot_count && current.hole_count == hole_count @@ -509,10 +519,13 @@ pub(crate) unsafe fn try_update_stable_tombstone_shape( let mut inner = table.inner.borrow_mut(); inner.facts_remove(current.facts_key_with_keys(keys as u64), id); } + // The summary is derived from the keys like every other mint's. + let summary = unsafe { crate::object::key_attrs::keys_summary(keys, logical_key_count) }; unsafe { (*record).logical_key_count = logical_key_count; (*record).live_inline_slot_count = live_inline_slot_count; (*record).hole_count = hole_count; + *record = (*record).with_summary(summary); (*record).set(RECORD_FLAG_FACTS_INDEXED, false); } super::debug_assert_object_shape_parity(obj); @@ -706,6 +719,7 @@ pub(crate) unsafe fn publish_object_shape_holes( current.object_kind, hole_count, current.proto_id, + super::receiver_extra_summary(obj), )); // #9200 THE FIX: stamp through the carrier-note funnel. This publish is // the one that minted a fresh (old_carrier=false) descriptor for an @@ -1115,6 +1129,9 @@ pub(super) fn install_external_shape_id( if !super::is_shape_id(id) || (keys.is_null() && logical_key_count != 0) { return false; } + // SAFETY: a live keys array or null; derived exactly as every mint does. + let summary = + unsafe { crate::object::key_attrs::keys_summary_checked(keys, logical_key_count) }; let keys = keys as usize as u64; let mut record = ShapeRecord::new( keys, @@ -1124,7 +1141,8 @@ pub(super) fn install_external_shape_id( super::ShapeObjectKind::Ordinary, 0, ) - .with_proto_id(proto_id); + .with_proto_id(proto_id) + .with_summary(summary); record.set(super::shapes_store::RECORD_FLAG_EXTERNAL_CARRIER, true); let table = &crate::state::state().shapes; let mut inner = table.inner.borrow_mut(); @@ -1138,6 +1156,7 @@ pub(super) fn install_external_shape_id( super::ShapeObjectKind::Ordinary, 0, proto_id, + summary, ); if matches { // SAFETY: same record and agent discipline as above. diff --git a/crates/perry-runtime/src/object/shapes_store.rs b/crates/perry-runtime/src/object/shapes_store.rs index e9c5f336a0..1e147a953b 100644 --- a/crates/perry-runtime/src/object/shapes_store.rs +++ b/crates/perry-runtime/src/object/shapes_store.rs @@ -64,7 +64,9 @@ pub(crate) struct ShapeRecord { pub(super) live_inline_slot_count: u32, pub(super) hole_count: u32, /// Low 8 bits: the `RECORD_FLAG_*` set. Bits 8-9: the `ShapeObjectKind` - /// discriminant. Bits 10-31: reserved. + /// discriminant. Bits 16-23: the attribute SUMMARY byte + /// (`key_attrs::SUMMARY_*`), an identity fact. Bits 10-15 and 24-31: + /// reserved. /// /// This word replaces the old `flags: u8` plus `_pad: [u8; 3]`. It is the /// same four bytes in the same place, so the record stays 32 bytes and @@ -75,6 +77,15 @@ pub(crate) struct ShapeRecord { const RECORD_KIND_SHIFT: u32 = 8; const RECORD_KIND_MASK: u32 = 0b11 << RECORD_KIND_SHIFT; +/// Charter step 3: the summary of the attributes the shape's keys carry — +/// what the chain store check and every per-key reader ask FIRST, so a shape +/// whose keys are all default answers without touching its keys. Derived +/// from `(keys, logical_key_count)` for a shape that publishes keys, which is +/// why folding it into identity costs no precision; a dictionary receiver's +/// shape publishes no keys and carries its private list's conservative +/// summary here instead. +const RECORD_SUMMARY_SHIFT: u32 = 16; +const RECORD_SUMMARY_MASK: u32 = 0xFF << RECORD_SUMMARY_SHIFT; const _: () = assert!(std::mem::size_of::() == 40); const _: () = assert!(std::mem::align_of::() == 8); @@ -122,6 +133,20 @@ impl ShapeRecord { self.has(RECORD_FLAG_CACHE_CARRIER | RECORD_FLAG_EXTERNAL_CARRIER) } + /// The attribute summary byte (see [`RECORD_SUMMARY_SHIFT`]). + #[inline] + pub(super) fn summary(&self) -> u8 { + ((self.flags_and_kind & RECORD_SUMMARY_MASK) >> RECORD_SUMMARY_SHIFT) as u8 + } + + /// The same record carrying attribute summary `summary`. + #[inline] + pub(super) fn with_summary(mut self, summary: u8) -> ShapeRecord { + self.flags_and_kind = (self.flags_and_kind & !RECORD_SUMMARY_MASK) + | (u32::from(summary) << RECORD_SUMMARY_SHIFT); + self + } + #[inline] pub(super) fn object_kind(&self) -> ShapeObjectKind { match (self.flags_and_kind & RECORD_KIND_MASK) >> RECORD_KIND_SHIFT { @@ -178,8 +203,10 @@ impl ShapeRecord { object_kind: ShapeObjectKind, hole_count: u32, proto_id: u64, + summary: u8, ) -> bool { self.proto_id == proto_id + && self.summary() == summary && self.facts_match( keys, logical_key_count, @@ -226,6 +253,7 @@ impl ShapeRecord { self.object_kind(), self.hole_count, self.proto_id, + self.summary(), ) } @@ -245,6 +273,7 @@ impl ShapeRecord { proto_id: self.proto_id, object_kind: self.object_kind(), hole_count: self.hole_count, + summary: self.summary(), } } } @@ -274,6 +303,7 @@ pub(super) fn facts_key( object_kind, hole_count, 0, + 0, ) } @@ -287,6 +317,7 @@ pub(super) fn facts_key_proto( object_kind: ShapeObjectKind, hole_count: u32, proto_id: u64, + summary: u8, ) -> u64 { const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; @@ -302,6 +333,11 @@ pub(super) fn facts_key_proto( // silent hash-quality loss, and the two kinds differ in every consumer. h = fold(h, object_kind.code()); h = fold(h, proto_id); + // Folded only when nonzero, so every attribute-free shape keeps the key + // it had before the summary existed. + if summary != 0 { + h = fold(h, 0x5_0000 | u64::from(summary)); + } // Final avalanche: FNV keeps most of its entropy in the high bits and // hashbrown's probe sequence starts from the LOW bits. h ^ (h >> 32) diff --git a/crates/perry-runtime/src/object/shapes_tests.rs b/crates/perry-runtime/src/object/shapes_tests.rs index dffa3eea11..8ed83921fd 100644 --- a/crates/perry-runtime/src/object/shapes_tests.rs +++ b/crates/perry-runtime/src/object/shapes_tests.rs @@ -425,7 +425,12 @@ mod descriptor_tests_8067 { let described = object_shape_id(obj); assert_ne!(described, structural); let described_facts = object_shape_descriptor(obj).unwrap(); - assert_ne!(described_facts.semantic_generation, 0); + // Charter step 3: the attribute is a fact the shape REPORTS — its + // keys carry it, and the summary says so. + assert_ne!( + described_facts.summary & crate::object::key_attrs::SUMMARY_NON_WRITABLE, + 0 + ); crate::object::prototype_chain::object_set_static_prototype( obj as usize, @@ -1234,6 +1239,7 @@ mod prototype_identity_tests { ShapeObjectKind::Ordinary, 0, proto_id, + 0, )) } diff --git a/crates/perry-runtime/src/object/string_wrapper.rs b/crates/perry-runtime/src/object/string_wrapper.rs index 53c0cbcc46..9a89ea9c4d 100644 --- a/crates/perry-runtime/src/object/string_wrapper.rs +++ b/crates/perry-runtime/src/object/string_wrapper.rs @@ -198,8 +198,9 @@ mod tests { .keys() .filter(|(ptr, _)| *ptr == owner) .count(), - 1, - "indices must not populate descriptor_state", + 0, + "indices must not populate descriptor_state (and `length`'s \ + attributes live with the keys, charter step 3)", ); assert!(has_index(owner, "4095")); assert!(!has_index(owner, "4096")); diff --git a/crates/perry-runtime/src/object/temporal_proto.rs b/crates/perry-runtime/src/object/temporal_proto.rs index 3267fd22e8..9a6acecbea 100644 --- a/crates/perry-runtime/src/object/temporal_proto.rs +++ b/crates/perry-runtime/src/object/temporal_proto.rs @@ -192,9 +192,10 @@ pub(super) fn populate_prototype( // `constructor` — `{ writable: true, enumerable: false, configurable: true }`. let ctor_val = crate::value::js_nanbox_pointer(ctor as i64); let ckey = crate::string::js_string_from_bytes(b"constructor".as_ptr(), 11); - js_object_set_field_by_name(proto, ckey, ctor_val); - super::set_builtin_property_attrs( - proto as usize, + super::define_builtin_data_property( + proto, + ckey, + ctor_val, "constructor".to_string(), super::PropertyAttrs::new(true, false, true), ); diff --git a/crates/perry-runtime/src/object/websocket_global.rs b/crates/perry-runtime/src/object/websocket_global.rs index 2860bc5cfb..27a2322a20 100644 --- a/crates/perry-runtime/src/object/websocket_global.rs +++ b/crates/perry-runtime/src/object/websocket_global.rs @@ -14,8 +14,7 @@ fn install_data_property( return; } let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); - js_object_set_field_by_name(obj, key, value); - super::set_builtin_property_attrs(obj as usize, name.to_string(), attrs); + super::define_builtin_data_property(obj, key, value, name.to_string(), attrs); } pub(super) fn install_constructor_shape( diff --git a/crates/perry-runtime/src/perf_hooks/prototypes.rs b/crates/perry-runtime/src/perf_hooks/prototypes.rs index b10d16a729..72889e853d 100644 --- a/crates/perry-runtime/src/perf_hooks/prototypes.rs +++ b/crates/perry-runtime/src/perf_hooks/prototypes.rs @@ -160,9 +160,10 @@ unsafe fn install_perf_method( enumerable: bool, ) { let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); - js_object_set_field_by_name(proto, key, value); - crate::object::set_builtin_property_attrs( - proto as usize, + crate::object::define_builtin_data_property( + proto, + key, + value, name.to_string(), crate::object::PropertyAttrs::new(true, enumerable, true), ); @@ -294,9 +295,10 @@ pub(crate) unsafe fn attach_perf_hooks_constructor( } let constructor_key = crate::string::js_string_from_bytes(b"constructor".as_ptr(), 11); - js_object_set_field_by_name(proto, constructor_key, constructor_value); - crate::object::set_builtin_property_attrs( - proto as usize, + crate::object::define_builtin_data_property( + proto, + constructor_key, + constructor_value, "constructor".to_string(), crate::object::PropertyAttrs::new(true, false, true), ); diff --git a/crates/perry-runtime/src/promise/then_probe.rs b/crates/perry-runtime/src/promise/then_probe.rs index d84837bfe9..4f2eb40922 100644 --- a/crates/perry-runtime/src/promise/then_probe.rs +++ b/crates/perry-runtime/src/promise/then_probe.rs @@ -210,7 +210,12 @@ unsafe fn proto_signature(proto_addr: usize) -> Option<(usize, u32, u16, u32)> { if len > cap { return None; } - len + // The RECEIVER's key count, from its shape — never the array's header + // length. A canonical backing serves every list on its growth chain, + // so a delete can return `Object.prototype` to a prefix of the same + // backing and a re-add to the longer list, with the address and the + // header length both unchanged (`ObjectKeys`). + keys_view.count() }; Some((keys_addr, keys_len, header._reserved, (*obj).class_id)) } diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index c5465ad3c5..fe414d109c 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -3322,10 +3322,10 @@ mod tests { "perry-codegen emits 0x200 for this bit" ); // It ADMITS a receiver, so it must not appear in the mask that REJECTS - // one (`WRITE_PIC_BLOCKING_FLAGS = 0x1987`) — a collision would make + // one (`WRITE_PIC_BLOCKING_FLAGS = 0x1180`) — a collision would make // every marked object permanently ineligible. - assert_eq!(crate::gc::OBJ_FLAG_PLAIN_ORDINARY & 0x1987, 0); - assert_ne!(crate::gc::OBJ_FLAG_PACKED_NUMERIC_PROOF & 0x1987, 0); + assert_eq!(crate::gc::OBJ_FLAG_PLAIN_ORDINARY & 0x1180, 0); + assert_ne!(crate::gc::OBJ_FLAG_PACKED_NUMERIC_PROOF & 0x1180, 0); // Bit 9 is shared with the array-only arguments-object flag, disjoint // by `obj_type`; and it must not collide with any object-meaningful // flag or with the survival-age / layout-state fields the GC owns. diff --git a/crates/perry-runtime/src/proxy/put_value.rs b/crates/perry-runtime/src/proxy/put_value.rs index e5d3b20eea..e130aa7ec1 100644 --- a/crates/perry-runtime/src/proxy/put_value.rs +++ b/crates/perry-runtime/src/proxy/put_value.rs @@ -491,11 +491,12 @@ pub extern "C" fn js_put_value_set_ic_miss( let Some(gc_header) = crate::value::addr_class::try_read_gc_header(obj_addr) else { return result; }; - const BLOCKING_FLAGS: u16 = crate::gc::OBJ_FLAG_FROZEN - | crate::gc::OBJ_FLAG_SEALED - | crate::gc::OBJ_FLAG_NO_EXTEND - | crate::gc::OBJ_FLAG_HAS_DESCRIPTORS - | crate::gc::OBJ_FLAG_TYPED_ARRAY_PROTO + // Charter step 3 (#10871): no integrity or descriptor bit. Whether + // THIS key may be overwritten is a fact of the receiver's keys (its + // attribute entry, `key_attrs.rs`), checked per key below before + // anything is primed; every attribute or integrity change moves the + // ShapeId, so the primed token pins it. + const BLOCKING_FLAGS: u16 = crate::gc::OBJ_FLAG_TYPED_ARRAY_PROTO // A generated hit cannot update/downgrade a typed layout without // calling the runtime. The miss store clears this bit; prime only // once that per-object downgrade is visible. @@ -560,6 +561,15 @@ pub extern "C" fn js_put_value_set_ic_miss( let Some(idx) = own_idx else { return result; }; + // Charter step 3: an accessor or a non-writable key (a frozen + // object's keys are all non-writable) is never primed. + if !crate::object::key_attrs::entry_is_plain_writable_data( + crate::object::key_attrs::keys_entry(keys, idx), + ) { + #[cfg(feature = "attr-census")] + crate::object::attr_census::note_global("ic.write_prime_declined.not_plain_key"); + return result; + } // #9287: a slot past the inline region primes too, carrying // IC_SLOT_OVERFLOW_BIT exactly like the dynamic-key IC's stub entries. // A way hit on such a slot is served by `dyn_ic_try_store` — diff --git a/crates/perry-runtime/src/proxy/put_value/packed_set.rs b/crates/perry-runtime/src/proxy/put_value/packed_set.rs index c6f1dfee3f..9149964493 100644 --- a/crates/perry-runtime/src/proxy/put_value/packed_set.rs +++ b/crates/perry-runtime/src/proxy/put_value/packed_set.rs @@ -26,15 +26,15 @@ //! list below its logical count, at an index below `live_inline_slot_count`. //! Spill-located keys are never published to the word (they keep the //! runtime-validated ways below). -//! * **a data property, writable** — rule 1 (#10824/#10287): every descriptor -//! install, removal or bulk clear transitions the ShapeId, and a data -//! descriptor's generation is a pure function of (predecessor, key, -//! attributes). So the receiver's per-key descriptor summary, vetted here -//! (`own_descriptors_skip_key`), holds for every carrier of `S`. -//! * **not frozen / sealed / non-extensible** — `set_integrity_flags` mints a -//! counter-unique semantic generation when it sets any of the three flags, -//! so an integrity-restricted receiver carries a private lineage that this -//! entry refuses to publish. +//! * **a data property, writable** — charter step 3: a key's attributes live +//! with the key, in the keys array the shape names (`key_attrs.rs`), so +//! the key's entry, vetted here, holds for every carrier of `S`. Every +//! descriptor install, removal or bulk clear changes the keys and with them +//! the ShapeId. +//! * **integrity** — `Object.freeze` makes every key non-writable (refused +//! above per key), and every integrity change mints a counter-unique +//! semantic generation, so a sealed or non-extensible receiver's writable +//! key may be published: an overwrite is not an add. //! * **not a class object, not a dictionary** — both are the shape's //! `object_kind` (`object_is_regular`). //! @@ -122,11 +122,11 @@ const SPILL_FLIP: u32 = crate::object::field_get_set::PACKED_SPILL_FLIP; /// typed-array-prototype flag and the numeric proof are per-object facts the /// hit path re-tests; refusing them here keeps the word's first carrier one the /// hit path admits. -const PACKED_SET_PRIME_BLOCKING: u16 = crate::gc::OBJ_FLAG_FROZEN - | crate::gc::OBJ_FLAG_SEALED - | crate::gc::OBJ_FLAG_NO_EXTEND - | crate::gc::OBJ_FLAG_TYPED_ARRAY_PROTO - | crate::gc::OBJ_FLAG_PACKED_NUMERIC_PROOF; +// Charter step 3: no integrity bit. An overwrite is refused only by a key that +// is an accessor or non-writable (a frozen object's keys all are), which the +// receiver's keys record per key and the prime checks below. +const PACKED_SET_PRIME_BLOCKING: u16 = + crate::gc::OBJ_FLAG_TYPED_ARRAY_PROTO | crate::gc::OBJ_FLAG_PACKED_NUMERIC_PROOF; /// Miss entry for the generated static-key store. Performs the full /// strict-aware `[[Set]]` (or a validated way store) and publishes what it @@ -358,20 +358,21 @@ unsafe fn prime_packed_set( return; } let key_interned = key_gc.gc_flags & crate::gc::GC_FLAG_INTERNED != 0; - // #10287 / rule 1: a descriptor on THIS key is a shape fact only through - // the per-key summary; a descriptor on another key leaves `key` plain data - // for every carrier of the same (deterministically minted) ShapeId. - if gc_header._reserved & crate::gc::OBJ_FLAG_HAS_DESCRIPTORS != 0 - && !crate::object::own_descriptors_skip_key( - obj_addr, - f64::from_bits(crate::value::js_nanbox_string(key as i64).to_bits()), + let Some(shape) = crate::object::shapes::object_shape_descriptor(obj) else { + return; + }; + // Charter step 3: whether THIS key may be overwritten is a fact of the + // shape the site is primed with — its keys record each key's attributes, + // and a descriptor on another key leaves this one plain writable data. + if shape.summary & crate::object::key_attrs::SUMMARY_BLOCKS_STORE != 0 + && !crate::object::key_attrs::entry_is_plain_writable_data( + crate::object::key_attrs::object_key_entry_for_string(obj, key), ) { + #[cfg(feature = "attr-census")] + crate::object::attr_census::note_global("ic.packed_prime_declined.not_plain_key"); return; } - let Some(shape) = crate::object::shapes::object_shape_descriptor(obj) else { - return; - }; // #10969 (step 2.5): the shape owns the key COUNT; the keys array may be a // canonical backing shared along a growth chain, whose header length is // the longest list's. Every lookup is bounded by the shape's count. diff --git a/crates/perry-runtime/src/proxy/put_value/packed_set_tests.rs b/crates/perry-runtime/src/proxy/put_value/packed_set_tests.rs index 5d05775fb7..7a714912d0 100644 --- a/crates/perry-runtime/src/proxy/put_value/packed_set_tests.rs +++ b/crates/perry-runtime/src/proxy/put_value/packed_set_tests.rs @@ -165,10 +165,22 @@ fn integrity_operations_leave_the_published_shape() { "{what} must move the receiver off the published ShapeId" ); let (_, restricted_word) = store_fresh(sibling, key, 2.0); - assert_eq!( - restricted_word, PACKED_SET_EMPTY, - "a receiver after {what} must not publish" - ); + if what == "freeze" { + // Every key of a frozen object is non-writable: a fact of its keys. + assert_eq!( + restricted_word, PACKED_SET_EMPTY, + "a receiver after {what} must not publish" + ); + } else { + // A sealed or non-extensible object's existing keys stay writable + // (charter step 3: the prime asks the key's attributes), so the + // receiver's OWN ShapeId may be published — never the primer's. + assert_eq!( + restricted_word as u32, + stamp(sibling), + "a receiver after {what} publishes its own ShapeId" + ); + } } } diff --git a/crates/perry-runtime/src/timer/handle_object.rs b/crates/perry-runtime/src/timer/handle_object.rs index 68e09ffe0b..41ffb79740 100644 --- a/crates/perry-runtime/src/timer/handle_object.rs +++ b/crates/perry-runtime/src/timer/handle_object.rs @@ -374,15 +374,12 @@ fn install_timer_constructor(proto: *mut crate::object::ObjectHeader, name: &str crate::object::native_module::set_bound_native_closure_name(closure, name); crate::object::native_module::set_builtin_closure_length(closure as usize, 0); let key = crate::string::js_string_from_bytes(b"constructor".as_ptr(), 11); - crate::object::js_object_set_field_by_name( + // Spec shape for a `constructor` property: writable, NOT enumerable, + // configurable — so it stays out of `Object.keys(proto)` and `for...in`. + crate::object::define_builtin_data_property( proto, key, crate::value::js_nanbox_pointer(closure as i64), - ); - // Spec shape for a `constructor` property: writable, NOT enumerable, - // configurable — so it stays out of `Object.keys(proto)` and `for...in`. - crate::object::set_builtin_property_attrs( - proto as usize, "constructor".to_string(), crate::object::PropertyAttrs::new(true, false, true), ); diff --git a/crates/perry-runtime/src/web_storage.rs b/crates/perry-runtime/src/web_storage.rs index 0abbdb0062..cd8bd861b9 100644 --- a/crates/perry-runtime/src/web_storage.rs +++ b/crates/perry-runtime/src/web_storage.rs @@ -210,9 +210,10 @@ pub(crate) fn install_storage_globals( install_storage_length_accessor(storage_proto); let constructor_key = string("constructor"); - crate::object::js_object_set_field_by_name(storage_proto, constructor_key, ctor_value); - crate::object::set_builtin_property_attrs( - storage_proto as usize, + crate::object::define_builtin_data_property( + storage_proto, + constructor_key, + ctor_value, "constructor".to_string(), PropertyAttrs::new(true, false, true), ); @@ -238,13 +239,10 @@ fn install_method(proto: *mut ObjectHeader, name: &str, func_ptr: *const u8, ari crate::closure::js_register_closure_arity(func_ptr, arity); crate::object::set_bound_native_closure_name(closure, name); crate::object::set_builtin_closure_length(closure as usize, 0); - crate::object::js_object_set_field_by_name( + crate::object::define_builtin_data_property( proto, string(name), crate::value::js_nanbox_pointer(closure as i64), - ); - crate::object::set_builtin_property_attrs( - proto as usize, name.to_string(), PropertyAttrs::new(true, true, true), ); @@ -310,13 +308,10 @@ fn make_storage_object(kind: StorageKind, proto: *mut ObjectHeader) -> *mut Obje crate::closure::js_register_closure_arity(func_ptr, arity); crate::object::set_bound_native_closure_name(closure, name); crate::object::set_builtin_closure_length(closure as usize, 0); - crate::object::js_object_set_field_by_name( + crate::object::define_builtin_data_property( obj, string(name), crate::value::js_nanbox_pointer(closure as i64), - ); - crate::object::set_builtin_property_attrs( - obj as usize, name.to_string(), PropertyAttrs::new(true, false, true), ); @@ -330,13 +325,10 @@ fn make_storage_object(kind: StorageKind, proto: *mut ObjectHeader) -> *mut Obje } fn set_global_storage_property(global: *mut ObjectHeader, name: &str, value: *mut ObjectHeader) { - crate::object::js_object_set_field_by_name( + crate::object::define_builtin_data_property( global, string(name), crate::value::js_nanbox_pointer(value as i64), - ); - crate::object::set_builtin_property_attrs( - global as usize, name.to_string(), PropertyAttrs::new(true, true, true), ); @@ -436,9 +428,10 @@ fn update_length(kind: StorageKind, len: usize) { fn update_length_on_obj(obj: *mut ObjectHeader, len: usize) { crate::object::clear_property_attrs(obj as usize, "length"); - crate::object::js_object_set_field_by_name(obj, string("length"), len as f64); - crate::object::set_builtin_property_attrs( - obj as usize, + crate::object::define_builtin_data_property( + obj, + string("length"), + len as f64, "length".to_string(), PropertyAttrs::new(false, false, true), ); diff --git a/scripts/raw_handle_debt_baseline.txt b/scripts/raw_handle_debt_baseline.txt index 8a655068e1..4564b1f0a0 100644 --- a/scripts/raw_handle_debt_baseline.txt +++ b/scripts/raw_handle_debt_baseline.txt @@ -1 +1 @@ -897 +896 diff --git a/scripts/raw_handle_debt_files.txt b/scripts/raw_handle_debt_files.txt index 5df798f1d6..aa559be145 100644 --- a/scripts/raw_handle_debt_files.txt +++ b/scripts/raw_handle_debt_files.txt @@ -105,6 +105,7 @@ 31 crates/perry-runtime/src/object/alloc.rs 2 crates/perry-runtime/src/object/array_object_ops.rs 2 crates/perry-runtime/src/object/bigint_dispatch.rs +1 crates/perry-runtime/src/object/cell_meta.rs # moved-from: crates/perry-runtime/src/object/mod.rs 3 crates/perry-runtime/src/object/class_registry/construct.rs 2 crates/perry-runtime/src/object/delete_rest.rs 12 crates/perry-runtime/src/object/descriptors.rs @@ -112,10 +113,9 @@ 2 crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs 8 crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs 9 crates/perry-runtime/src/object/field_set_by_name/tail.rs -9 crates/perry-runtime/src/object/global_this/populate.rs +8 crates/perry-runtime/src/object/global_this/populate.rs 5 crates/perry-runtime/src/object/global_this_webassembly.rs 2 crates/perry-runtime/src/object/meta_accessors.rs -1 crates/perry-runtime/src/object/mod.rs 3 crates/perry-runtime/src/object/namespace_create.rs 1 crates/perry-runtime/src/object/native_call_method/object_proto.rs 4 crates/perry-runtime/src/object/native_call_method/primitive_methods.rs diff --git a/test-files/test_gap_attrs_in_shape.ts b/test-files/test_gap_attrs_in_shape.ts new file mode 100644 index 0000000000..9fcb898648 --- /dev/null +++ b/test-files/test_gap_attrs_in_shape.ts @@ -0,0 +1,368 @@ +// Charter step 3: property attributes are facts of the SHAPE. +// +// Every row here is a place where a shape that forgot an attribute, or a cache +// that trusted a shape without asking what it says about the key, returns a +// WRONG VALUE rather than a slow one. Hot loops prime the read/write inline +// caches first, then the attribute changes, then the same sites run again. +// Output must be byte-identical to node. + +function out(label: string, v: unknown): void { + console.log(label + ": " + (typeof v === "string" ? v : JSON.stringify(v))); +} + +function tryRun(label: string, f: () => unknown): void { + try { + out(label, f()); + } catch (e) { + out(label, "threw " + (e as Error).constructor.name); + } +} + +// --- 1. one non-writable key must not slow or break the object's other keys (#10871) +function readA(o: any): number { + return o.a; +} +function writeA(o: any, v: number): void { + o.a = v; +} +{ + const O: any = { a: 1, b: 2, c: 3, d: 4 }; + let h = 0; + for (let k = 0; k < 2000; k++) { + writeA(O, k); + h += readA(O); + } + Object.defineProperty(O, "z", { value: 7, writable: false, enumerable: false, configurable: false }); + for (let k = 0; k < 2000; k++) { + writeA(O, k); + h += readA(O); + } + out("1.sum", h); + tryRun("1.z-write-strict", () => { + O.z = 9; + return O.z; + }); + out("1.z", O.z); + out("1.keys", Object.keys(O)); + out("1.json", JSON.stringify(O)); + out("1.desc-z", Object.getOwnPropertyDescriptor(O, "z")); + out("1.desc-a", Object.getOwnPropertyDescriptor(O, "a")); +} + +// --- 2. a primed store site meets a receiver whose key turned non-writable +{ + const objs: any[] = []; + for (let i = 0; i < 8; i++) objs.push({ p: i, q: i * 2 }); + function storeP(o: any, v: number): void { + o.p = v; + } + for (let r = 0; r < 500; r++) for (const o of objs) storeP(o, r); + Object.defineProperty(objs[3], "p", { writable: false }); + const results: string[] = []; + for (let i = 0; i < objs.length; i++) { + try { + storeP(objs[i], 1000 + i); + results.push(String(objs[i].p)); + } catch (e) { + results.push("threw:" + objs[i].p); + } + } + out("2.stores", results.join(",")); +} + +// --- 3. a primed read site meets a receiver whose key became an accessor +{ + const objs: any[] = []; + for (let i = 0; i < 6; i++) objs.push({ v: i, w: 0 }); + function readV(o: any): number { + return o.v; + } + let s = 0; + for (let r = 0; r < 500; r++) for (const o of objs) s += readV(o); + let calls = 0; + Object.defineProperty(objs[2], "v", { + get() { + calls++; + return 100; + }, + configurable: true, + enumerable: true, + }); + let t = 0; + for (let r = 0; r < 10; r++) for (const o of objs) t += readV(o); + out("3.before", s); + out("3.after", t); + out("3.getter-calls", calls); + // Replacing the getter keeps the attributes identical: the new one must run. + Object.defineProperty(objs[2], "v", { get: () => 200, configurable: true, enumerable: true }); + out("3.replaced", readV(objs[2])); + // Back to a data property. + Object.defineProperty(objs[2], "v", { value: 5, writable: true, configurable: true, enumerable: true }); + out("3.data-again", readV(objs[2])); + out("3.desc", Object.getOwnPropertyDescriptor(objs[2], "v")); +} + +// --- 4. setters on one of two look-alike objects +{ + const log: number[] = []; + const plain: any = { x: 1, y: 2 }; + const spied: any = { x: 1, y: 2 }; + function setX(o: any, v: number): void { + o.x = v; + } + for (let i = 0; i < 300; i++) { + setX(plain, i); + setX(spied, i); + } + let backing = 0; + Object.defineProperty(spied, "x", { + get: () => backing, + set: (v: number) => { + log.push(v); + backing = v * 10; + }, + configurable: true, + enumerable: true, + }); + for (let i = 0; i < 3; i++) { + setX(plain, i); + setX(spied, i); + } + out("4.plain", plain.x); + out("4.spied", spied.x); + out("4.log", log); +} + +// --- 5. enumerability reaches every enumeration path +{ + const o: any = { a: 1, b: 2, c: 3 }; + Object.defineProperty(o, "b", { enumerable: false }); + Object.defineProperty(o, "hidden", { value: 9, enumerable: false, writable: true, configurable: true }); + const forIn: string[] = []; + for (const k in o) forIn.push(k); + out("5.keys", Object.keys(o)); + out("5.forin", forIn); + out("5.json", JSON.stringify(o)); + out("5.spread", { ...o }); + out("5.assign", Object.assign({}, o)); + out("5.entries", Object.entries(o)); + out("5.values", Object.values(o)); + out("5.names", Object.getOwnPropertyNames(o)); + out("5.propIsEnum", [o.propertyIsEnumerable("a"), o.propertyIsEnumerable("b"), o.propertyIsEnumerable("hidden")]); + out("5.descs", Object.getOwnPropertyDescriptors(o)); +} + +// --- 6. freeze / seal / preventExtensions on primed sites +{ + const o: any = { n: 1, m: 2 }; + function setN(t: any, v: number): void { + t.n = v; + } + function addK(t: any, i: number): void { + t["k" + (i % 3)] = i; + } + for (let i = 0; i < 300; i++) setN(o, i); + const sealed: any = { n: 1, m: 2 }; + for (let i = 0; i < 300; i++) setN(sealed, i); + const noext: any = { n: 1, m: 2 }; + for (let i = 0; i < 300; i++) setN(noext, i); + + Object.freeze(o); + Object.seal(sealed); + Object.preventExtensions(noext); + tryRun("6.frozen-store", () => { + setN(o, 42); + return o.n; + }); + tryRun("6.sealed-store", () => { + setN(sealed, 42); + return sealed.n; + }); + tryRun("6.noext-store", () => { + setN(noext, 42); + return noext.n; + }); + tryRun("6.frozen-add", () => { + addK(o, 1); + return Object.keys(o); + }); + tryRun("6.sealed-add", () => { + addK(sealed, 1); + return Object.keys(sealed); + }); + tryRun("6.noext-add", () => { + addK(noext, 1); + return Object.keys(noext); + }); + tryRun("6.sealed-delete", () => delete sealed.m); + tryRun("6.noext-delete", () => delete noext.m); + out("6.state", [ + Object.isFrozen(o), + Object.isSealed(o), + Object.isExtensible(o), + Object.isFrozen(sealed), + Object.isSealed(sealed), + Object.isExtensible(sealed), + Object.isFrozen(noext), + Object.isSealed(noext), + Object.isExtensible(noext), + ]); + out("6.desc-frozen", Object.getOwnPropertyDescriptor(o, "n")); + out("6.desc-sealed", Object.getOwnPropertyDescriptor(sealed, "n")); + // A sealed key may still become non-writable. + Object.defineProperty(sealed, "n", { writable: false }); + tryRun("6.sealed-then-ro", () => { + setN(sealed, 7); + return sealed.n; + }); + out("6.sealed-now-frozen", Object.isFrozen(sealed)); + // An object that is non-extensible with every key made non-configurable and + // non-writable IS frozen, however it got there. + const manual: any = { a: 1 }; + Object.defineProperty(manual, "a", { writable: false, configurable: false }); + Object.preventExtensions(manual); + out("6.manual-frozen", [Object.isFrozen(manual), Object.isSealed(manual)]); + const empty: any = {}; + Object.preventExtensions(empty); + out("6.empty-frozen", Object.isFrozen(empty)); +} + +// --- 7. attributes do not survive a delete + re-add +{ + const o: any = { a: 1, b: 2 }; + Object.defineProperty(o, "a", { value: 5, writable: false, enumerable: false, configurable: true }); + delete o.a; + o.a = 6; + o.a = 7; + out("7.readded", o.a); + out("7.desc", Object.getOwnPropertyDescriptor(o, "a")); + out("7.keys", Object.keys(o)); +} + +// --- 8. attributes ride key additions (the successor shape keeps them) +{ + const o: any = { a: 1 }; + Object.defineProperty(o, "a", { writable: false }); + for (let i = 0; i < 20; i++) o["x" + i] = i; + tryRun("8.after-adds", () => { + o.a = 99; + return o.a; + }); + out("8.desc", Object.getOwnPropertyDescriptor(o, "a")); + // ... and a delete of another key. + delete o.x3; + tryRun("8.after-delete", () => { + o.a = 98; + return o.a; + }); +} + +// --- 9. two receivers reach the same attributes by different paths +{ + const p: any = { a: 1, b: 2 }; + const q: any = { a: 1, b: 2 }; + Object.defineProperty(p, "a", { enumerable: false }); + Object.defineProperty(p, "b", { writable: false }); + Object.defineProperty(q, "b", { writable: false }); + Object.defineProperty(q, "a", { enumerable: false }); + function probe(o: any): string { + const r: string[] = []; + try { + o.b = 10; + r.push("b=" + o.b); + } catch (e) { + r.push("b-threw"); + } + r.push(JSON.stringify(Object.keys(o))); + return r.join(" "); + } + out("9.p", probe(p)); + out("9.q", probe(q)); +} + +// --- 10. zod's pattern: one non-enumerable key, then many ordinary assignments +{ + class Inst { + [k: string]: any; + constructor(tag: number) { + Object.defineProperty(this, "_zod", { value: { tag }, enumerable: false, configurable: true, writable: true }); + for (let i = 0; i < 40; i++) this["p" + i] = i + tag; + } + } + let s = 0; + const all: Inst[] = []; + for (let n = 0; n < 50; n++) all.push(new Inst(n)); + function readP17(o: any): number { + return o.p17; + } + for (const o of all) s += readP17(o) + o._zod.tag; + for (const o of all) o.p17 = 1; + for (const o of all) s += readP17(o); + out("10.sum", s); + out("10.keys", Object.keys(all[7]).length); + out("10.json-has-zod", JSON.stringify(all[7]).includes("_zod")); +} + +// --- 11. inherited non-writable and inherited setter +{ + const proto: any = {}; + Object.defineProperty(proto, "ro", { value: 1, writable: false, configurable: true, enumerable: true }); + let seen = -1; + Object.defineProperty(proto, "st", { + set(v: number) { + seen = v; + }, + get() { + return seen; + }, + configurable: true, + }); + const child: any = Object.create(proto); + child.other = 1; + tryRun("11.inherited-ro", () => { + child.ro = 5; + return Object.prototype.hasOwnProperty.call(child, "ro"); + }); + child.st = 33; + out("11.inherited-setter", [seen, Object.prototype.hasOwnProperty.call(child, "st")]); +} + +// --- 12. symbol-keyed attributes +{ + const s = Symbol("s"); + const o: any = { a: 1 }; + Object.defineProperty(o, s, { value: 3, writable: false, enumerable: false }); + tryRun("12.sym-store", () => { + o[s] = 4; + return o[s]; + }); + out("12.sym-desc", String(Object.getOwnPropertyDescriptor(o, s)!.writable)); + out("12.sym-listed", Object.getOwnPropertySymbols(o).length); +} + +// --- 13. getters and setters declared in an object literal +{ + let n = 0; + const lit: any = { + base: 2, + get twice() { + return this.base * 2; + }, + set twice(v: number) { + n = v; + }, + }; + function readTwice(o: any): number { + return o.twice; + } + let s = 0; + for (let i = 0; i < 200; i++) { + lit.base = i; + s += readTwice(lit); + } + lit.twice = 9; + out("13.sum", s); + out("13.set", n); + out("13.desc-kind", typeof Object.getOwnPropertyDescriptor(lit, "twice")!.get); + out("13.keys", Object.keys(lit)); +} diff --git a/test-files/test_gap_attrs_in_shape_sloppy.cts b/test-files/test_gap_attrs_in_shape_sloppy.cts new file mode 100644 index 0000000000..cc26d7b507 --- /dev/null +++ b/test-files/test_gap_attrs_in_shape_sloppy.cts @@ -0,0 +1,44 @@ +// Charter step 3, SLOPPY mode: a store the attributes forbid fails SILENTLY +// here (strict mode throws; see test_gap_attrs_in_shape.ts). Every site is +// primed before the attribute changes. + +function out(label: string, v: unknown): void { + console.log(label + ": " + (typeof v === "string" ? v : JSON.stringify(v))); +} + +function setA(o: any, v: number): void { + o.a = v; +} +function addK(o: any, i: number): void { + o["k" + i] = i; +} + +const plain: any = { a: 1, b: 2 }; +const ro: any = { a: 1, b: 2 }; +const frozen: any = { a: 1, b: 2 }; +const sealed: any = { a: 1, b: 2 }; +const noext: any = { a: 1, b: 2 }; +const all = [plain, ro, frozen, sealed, noext]; +for (let i = 0; i < 400; i++) for (const o of all) setA(o, i); + +Object.defineProperty(ro, "a", { writable: false }); +Object.freeze(frozen); +Object.seal(sealed); +Object.preventExtensions(noext); + +// Several stores each: a miss that primes the site must not let a LATER +// store through the inline path. +for (let r = 0; r < 3; r++) for (const o of all) setA(o, 1000 + r); +for (const o of all) addK(o, 7); +out("a", all.map((o) => o.a)); +out("keys", all.map((o) => Object.keys(o).join("|"))); +out("delete-b", all.map((o) => delete o.b)); +out("has-b", all.map((o) => Object.prototype.hasOwnProperty.call(o, "b"))); + +let got = 0; +const acc: any = { v: 1 }; +for (let i = 0; i < 300; i++) got += acc.v; +Object.defineProperty(acc, "v", { get: () => 5, configurable: true }); +acc.v = 77; // getter-only accessor: silently ignored +got += acc.v; +out("getter-only", got); diff --git a/test-files/test_gap_attrs_with_keys.ts b/test-files/test_gap_attrs_with_keys.ts new file mode 100644 index 0000000000..04c211d385 --- /dev/null +++ b/test-files/test_gap_attrs_with_keys.ts @@ -0,0 +1,263 @@ +// Charter step 3: a key's attributes live WITH the key, in the keys array. +// +// Key lists are shared: one canonical backing serves every object whose keys +// are a prefix of it, and a dictionary-mode object keeps a private list that +// it edits in place. Each row below is a way a list that forgot to carry its +// attributes — through a fork, a copy, a delete, a latch or a rebuild — shows +// up as a WRONG VALUE on some other object or some other key. Output must be +// byte-identical to node. + +function out(label: string, v: unknown): void { + console.log(label + ": " + (typeof v === "string" ? v : JSON.stringify(v))); +} + +function attempt(label: string, f: () => unknown): void { + try { + out(label, f()); + } catch (e) { + out(label, "threw " + (e as Error).constructor.name); + } +} + +function summary(o: any, key: string): string { + const d = Object.getOwnPropertyDescriptor(o, key); + if (!d) return "absent"; + const kind = "value" in d ? "data" : "accessor"; + return [kind, d.writable === false ? "ro" : "w", d.enumerable ? "e" : "ne", d.configurable ? "c" : "nc"].join("/"); +} + +// --- 1. siblings on one backing: an attribute on one never reaches the other +{ + const a: any = { p: 1, q: 2, r: 3 }; + const b: any = { p: 1, q: 2, r: 3 }; + Object.defineProperty(a, "q", { writable: false }); + a.s = 4; + b.s = 4; + attempt("1.a-q", () => { + a.q = 9; + return a.q; + }); + attempt("1.b-q", () => { + b.q = 9; + return b.q; + }); + out("1.a", [summary(a, "p"), summary(a, "q"), summary(a, "r"), summary(a, "s")]); + out("1.b", [summary(b, "p"), summary(b, "q"), summary(b, "r"), summary(b, "s")]); +} + +// --- 2. the same key, added with and without attributes, at the same position +{ + const plain: any = { k0: 0 }; + const acc: any = { k0: 0 }; + plain.k1 = 1; + Object.defineProperty(acc, "k1", { get: () => 11, enumerable: true, configurable: true }); + plain.k2 = 2; + acc.k2 = 2; + out("2.plain", [plain.k1, summary(plain, "k1"), Object.keys(plain)]); + out("2.acc", [acc.k1, summary(acc, "k1"), Object.keys(acc)]); +} + +// --- 3. an exports object: a getter per re-export, appended in order +{ + const exp: any = {}; + Object.defineProperty(exp, "__esModule", { value: true }); + const names: string[] = []; + for (let i = 0; i < 300; i++) names.push("n" + i); + for (const n of names) { + const v = n.length; + Object.defineProperty(exp, n, { enumerable: true, get: () => v }); + } + let s = 0; + for (const n of names) s += exp[n]; + out("3.sum", s); + out("3.keys", Object.keys(exp).length); + out("3.first", [summary(exp, "__esModule"), summary(exp, "n0"), summary(exp, "n299")]); + attempt("3.write-getter", () => { + exp.n5 = 1; + return exp.n5; + }); + // A second exports object with the same keys shares the layout and must + // read its own closures. + const exp2: any = {}; + Object.defineProperty(exp2, "__esModule", { value: true }); + for (const n of names) { + const v = n.length * 2; + Object.defineProperty(exp2, n, { enumerable: true, get: () => v }); + } + let s2 = 0; + for (const n of names) s2 += exp2[n]; + out("3.sum2", s2); +} + +// --- 4. change a MIDDLE key: the tail is rebuilt, a sibling keeps its list +{ + const mk = () => ({ a: 1, b: 2, c: 3, d: 4, e: 5 }); + const x: any = mk(); + const y: any = mk(); + Object.defineProperty(x, "b", { enumerable: false }); + x.f = 6; + y.f = 6; + out("4.x", [Object.keys(x), summary(x, "b"), summary(x, "f")]); + out("4.y", [Object.keys(y), summary(y, "b"), summary(y, "f")]); + Object.defineProperty(x, "b", { enumerable: true }); + out("4.x-restored", [Object.keys(x), summary(x, "b")]); +} + +// --- 5. delete from a list that carries attributes: positions shift, attributes follow +{ + const o: any = { a: 1, b: 2, c: 3, d: 4 }; + Object.defineProperty(o, "c", { writable: false, enumerable: false }); + Object.defineProperty(o, "d", { get: () => 44, enumerable: true, configurable: true }); + delete o.a; + out("5.keys", Object.keys(o)); + out("5.attrs", [summary(o, "b"), summary(o, "c"), summary(o, "d")]); + attempt("5.c-write", () => { + o.c = 9; + return o.c; + }); + out("5.d", o.d); + delete o.b; + out("5.after-b", [Object.keys(o), summary(o, "c"), summary(o, "d"), o.d]); + o.a = 10; + out("5.readd", [Object.keys(o), summary(o, "a")]); +} + +// --- 6. a dictionary-mode object: many unique keys, then attributes, deletes, adds +{ + const dict: any = {}; + for (let i = 0; i < 2000; i++) dict["u" + i * 7] = i; + Object.defineProperty(dict, "u70", { writable: false, enumerable: false }); + Object.defineProperty(dict, "u140", { get: () => -1, enumerable: true, configurable: true }); + for (let i = 0; i < 100; i++) delete dict["u" + i * 14]; + for (let i = 0; i < 50; i++) dict["v" + i] = i; + Object.defineProperty(dict, "v7", { enumerable: false, value: 77 }); + const keys = Object.keys(dict); + out("6.count", keys.length); + out("6.has", ["u70" in dict, "u140" in dict, "u7" in dict]); + out("6.attrs", [summary(dict, "u7"), summary(dict, "u21"), summary(dict, "v7"), summary(dict, "v8")]); + attempt("6.u21", () => dict.u21); + let s = 0; + for (const k of keys) s += dict[k]; + out("6.sum", s); + Object.freeze(dict); + attempt("6.frozen-write", () => { + dict.u7 = 1; + return dict.u7; + }); + out("6.frozen", [Object.isFrozen(dict), summary(dict, "u7"), summary(dict, "v8")]); +} + +// --- 7. freeze after tombstones and re-adds +{ + const o: any = {}; + for (let i = 0; i < 20; i++) o["t" + i] = i; + for (let i = 0; i < 20; i += 3) delete o["t" + i]; + o.t0 = 100; + Object.freeze(o); + out("7.keys", Object.keys(o)); + out("7.frozen", [Object.isFrozen(o), summary(o, "t0"), summary(o, "t1")]); + attempt("7.write", () => { + o.t1 = 5; + return o.t1; + }); +} + +// --- 8. a primed read site over many layouts that differ only in attributes +{ + function readM(o: any): number { + return o.m; + } + const objs: any[] = []; + for (let i = 0; i < 6; i++) { + const o: any = { l: i, m: i * 10, n: i }; + if (i % 2 === 1) { + const v = i * 1000; + Object.defineProperty(o, "m", { get: () => v, enumerable: true, configurable: true }); + } else if (i % 3 === 0) { + Object.defineProperty(o, "m", { writable: false }); + } + objs.push(o); + } + let s = 0; + for (let r = 0; r < 200; r++) for (const o of objs) s += readM(o); + out("8.sum", s); + const w: string[] = []; + for (const o of objs) { + try { + o.m = 1; + w.push(String(o.m)); + } catch (e) { + w.push("threw"); + } + } + out("8.writes", w.join(",")); +} + +// --- 9. class prototypes: an accessor and a read-only key on the chain +{ + class Base { + x = 1; + } + Object.defineProperty(Base.prototype, "ro", { value: 5, writable: false, configurable: true }); + let seen = 0; + Object.defineProperty(Base.prototype, "acc", { + get() { + return seen; + }, + set(v: number) { + seen = v * 2; + }, + configurable: true, + }); + const items: any[] = []; + for (let i = 0; i < 5; i++) items.push(new Base()); + function setAll(v: number): string { + const r: string[] = []; + for (const it of items) { + try { + it.ro = v; + r.push(String(Object.prototype.hasOwnProperty.call(it, "ro"))); + } catch (e) { + r.push("threw"); + } + it.acc = v; + } + return r.join(",") + " seen=" + seen; + } + out("9.set", setAll(3)); + delete (Base.prototype as any).ro; + out("9.after-delete", setAll(4)); +} + +// --- 10. a receiver in stable-tombstone mode moves onto a shared list with +// attributes, then gains an accessor: the accessor must be reported (its +// ShapeId must not be updated in place as if the list were still private) +{ + for (const mk of [() => ({}), () => JSON.parse("{}")]) { + const o: any = mk(); + o.a = 1; + o.dynamic = "x"; + o.extra = 3; + delete o.a; + o.a = 7; + Object.defineProperty(o, "hidden", { value: 99, enumerable: false }); + Object.defineProperty(o, "getter", { get: () => "seen", enumerable: true }); + const d: any = Object.getOwnPropertyDescriptor(o, "getter"); + out("10.desc", [typeof d.get, d.enumerable, d.configurable, o.getter]); + out("10.json", JSON.stringify(o)); + } +} + +// --- 11. replacing an accessor's function under unchanged attributes +{ + const o: any = {}; + Object.defineProperty(o, "v", { get: () => 1, configurable: true }); + function readV(x: any): number { + return x.v; + } + let s = 0; + for (let i = 0; i < 300; i++) s += readV(o); + Object.defineProperty(o, "v", { get: () => 2, configurable: true }); + for (let i = 0; i < 300; i++) s += readV(o); + out("11.sum", s); +} From ca007c65baae410042559884f1235e457a11e0aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 26 Sep 2026 10:11:30 +0000 Subject: [PATCH 2/2] changelog: name the fragment after PR #11411 --- changelog.d/{attrs-with-keys.md => 11411-attrs-with-keys.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{attrs-with-keys.md => 11411-attrs-with-keys.md} (100%) diff --git a/changelog.d/attrs-with-keys.md b/changelog.d/11411-attrs-with-keys.md similarity index 100% rename from changelog.d/attrs-with-keys.md rename to changelog.d/11411-attrs-with-keys.md