From 3f697e4e17e6bee9dece6d80fd94062aba2da1f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 26 Sep 2026 10:27:55 +0000 Subject: [PATCH 01/12] perf(runtime): a function's own properties live in the function object (D1) ClosureHeader::props now points at the function's own-property BAG: a runtime-internal null-prototype ObjectHeader whose keys and slots are the function's own string-keyed data properties in creation order, created on the first own write and installed with the object-slot barrier. It is a traced, rewritten raw-pointer child edge of the closure (the ClosureCaptures arm), so it moves and dies with the function. The #3655 deleted-synthesized-key markers and the recorded [[Prototype]] live in the bag's meta.expando state record, another null-prototype object. Deleted: CLOSURE_PROPS, CLOSURE_DELETED_KEYS and CLOSURE_STATIC_PROTOTYPES (three process-global Mutex tables), their young log, the re-key-on-move, dead-owner prune, dead-payload clear and root scanner work for them, and the rewrite-arm side-table visits. What stays closure-address-keyed is the wasm-host funcref table. A function with own keys gets a KEYED Function ShapeId (the bag's keys, count and inline bound, the body kind's prototype; canonical per facts, pinned as an external carrier because the collector does not note closures as carriers), so it stays described instead of falling to FunctionDictionary; the method arm accepts any described Function shape naming Function.prototype whose key list lacks the method name. Writers run under GcSuppressScope because the callers hold raw closure addresses across the call. --- crates/perry-runtime/src/closure/alloc.rs | 7 +- .../src/closure/dynamic_props.rs | 1047 ++--------------- crates/perry-runtime/src/closure/mod.rs | 2 +- crates/perry-runtime/src/closure/props.rs | 265 +++++ crates/perry-runtime/src/closure/shape.rs | 145 ++- crates/perry-runtime/src/gc/layout.rs | 17 + .../perry-runtime/src/gc/layout_slot_visit.rs | 7 +- crates/perry-runtime/src/gc/tests/barrier.rs | 5 +- .../src/gc/tests/global_sink_isolation.rs | 34 - .../src/gc/tests/promote_in_place.rs | 50 - .../src/gc/tests/young_log_tests.rs | 91 +- .../native_call_method/function_shape.rs | 25 +- scripts/addr_class_ratchet_baseline.txt | 1 - scripts/gc_rekeyed_key_tables.json | 6 +- scripts/gc_runtime_root_holders.json | 6 - 15 files changed, 570 insertions(+), 1138 deletions(-) create mode 100644 crates/perry-runtime/src/closure/props.rs diff --git a/crates/perry-runtime/src/closure/alloc.rs b/crates/perry-runtime/src/closure/alloc.rs index 67e38773d3..1e4695af69 100644 --- a/crates/perry-runtime/src/closure/alloc.rs +++ b/crates/perry-runtime/src/closure/alloc.rs @@ -367,10 +367,9 @@ pub struct ClosureHeader { pub shape_id: u32, /// Function pointer (the actual compiled function). pub func_ptr: *const u8, - /// Reserved for the function object's shaped own-property record (D1). - /// ALWAYS NULL in this stage and not yet enumerated by the collector: - /// the stage that first writes it must make it a traced, rewritten - /// raw-pointer child edge (and barrier the store) in the same change. + /// The function object's own-property bag (`closure::props`, D1): null + /// until the first own property, then a traced, rewritten raw-pointer + /// child edge (`gc::layout`'s `ClosureCaptures` arm). pub props: *mut crate::object::ObjectHeader, } diff --git a/crates/perry-runtime/src/closure/dynamic_props.rs b/crates/perry-runtime/src/closure/dynamic_props.rs index 0611bb2a19..174c500cf0 100644 --- a/crates/perry-runtime/src/closure/dynamic_props.rs +++ b/crates/perry-runtime/src/closure/dynamic_props.rs @@ -1,100 +1,22 @@ -//! Dynamic per-closure property side-table, `this`-rebind/unbind helpers, -//! and the closure-magic-tag pointer predicate. +//! A function object's own properties, `this`-rebind/unbind helpers, and the +//! closure kind predicate. +//! +//! Own properties live IN the function object (D1, the every-receiver-shape +//! lane): `ClosureHeader::props` points at a runtime-internal null-prototype +//! `ObjectHeader` (the "bag", `closure::props`) whose keys and slots are the +//! function's own string-keyed data properties, in ordinary creation order. +//! The bag is a traced child edge of the closure, so it moves and dies with +//! it; the three address-keyed side tables it replaces (own values, deleted +//! synthesized keys, recorded [[Prototype]]) and their young log, re-key hook, +//! dead-owner prune and root scanner are gone. What remains keyed by address +//! is the wasm-host funcref table below. use super::*; +#[cfg(feature = "wasm-host")] use crate::fast_hash::{new_ptr_hash_map, PtrHashMap}; -use std::collections::{HashMap, HashSet}; +#[cfg(feature = "wasm-host")] use std::sync::{Mutex, OnceLock}; -/// Per-function dynamic properties with ordinary property-creation order. -/// -/// Reads stay O(1) through the hash map, while `order` records the string-key -/// insertion order required by `OrdinaryOwnPropertyKeys`. Updating an existing -/// property leaves its position unchanged; deleting it removes the position so -/// a later re-add appends it at the end. -#[derive(Default)] -struct ClosureProps { - values: HashMap, - order: Vec, -} - -impl ClosureProps { - fn contains_key(&self, key: &str) -> bool { - self.values.contains_key(key) - } - - fn get(&self, key: &str) -> Option<&f64> { - self.values.get(key) - } - - fn insert(&mut self, key: String, value: f64) { - if !self.values.contains_key(&key) { - self.order.push(key.clone()); - } - self.values.insert(key, value); - } - - fn remove(&mut self, key: &str) -> Option { - let value = self.values.remove(key)?; - self.order.retain(|existing| existing != key); - Some(value) - } - - fn merge_older(&mut self, mut older: ClosureProps) { - let newer = std::mem::take(self); - for key in newer.order { - if older.values.contains_key(&key) { - continue; - } - if let Some(value) = newer.values.get(&key).copied() { - older.insert(key, value); - } - } - *self = older; - } - - fn snapshot(&self) -> Vec<(String, f64)> { - let mut indexed = Vec::new(); - let mut strings = Vec::new(); - for key in &self.order { - let Some(value) = self.values.get(key).copied() else { - continue; - }; - if let Some(index) = crate::object::canonical_array_index(key) { - indexed.push((index, key.clone(), value)); - } else { - strings.push((key.clone(), value)); - } - } - crate::cold_sort::sort_by_key(&mut indexed, |(index, _, _)| *index); - indexed - .into_iter() - .map(|(_, key, value)| (key, value)) - .chain(strings) - .collect() - } -} - -per_test_global! { - /// OUTER key is a closure heap address, probed on every dynamic property - /// get/set/delete on a function object, so it takes `PtrHasher` for the - /// same reason as the other pointer-keyed registries (#8125): a raw - /// address is already well distributed and no external input reaches it. - /// - /// `ClosureProps::values` deliberately keeps std's SipHash. Its keys are JS - /// property names, and unlike the descriptor side tables' program-identifier - /// keys these can be computed at runtime from program input - /// (`fn[userSuppliedName] = 1`), which is exactly the adversarial case - /// `RandomState` exists to defend. It is also not the half the profile - /// implicates -- `hash_one::<&usize>` is the outer probe. - static CLOSURE_PROPS: OnceLock>> = - OnceLock::new(); -} - -fn get_closure_props() -> &'static Mutex> { - crate::once_init::get_or_init(&CLOSURE_PROPS, || Mutex::new(new_ptr_hash_map())) -} - #[cfg(feature = "wasm-host")] per_test_global! { /// Host-owned funcref handles whose JavaScript wrappers are closures. @@ -119,7 +41,6 @@ pub(crate) fn register_wasm_funcref_external(owner: usize, handle: usize) { drop_wasm_funcref_external(handle); return; } - note_young_closure_owner(owner, 0); let replaced = match get_wasm_funcref_externals().lock() { Ok(mut externals) => externals.insert(owner, handle), Err(_) => Some(handle), @@ -129,110 +50,24 @@ pub(crate) fn register_wasm_funcref_external(owner: usize, handle: usize) { } } -crate::perry_thread_local! { - /// #9754: this thread's young-entry log for the closure side tables — - /// the owners whose entry may hold a pointer a minor can act on, as key - /// or as value. Thread-local although the tables are process-global: an - /// entry's addresses belong to the inserting thread's heap, and only that - /// thread's minors can move or free them. See `gc/young_log.rs`. - static CLOSURE_YOUNG_OWNERS: std::cell::RefCell> = - const { std::cell::RefCell::new(crate::gc::young_log::YoungLog::new()) }; - #[cfg(test)] - static TEST_SUPPRESS_CLOSURE_YOUNG_NOTE: std::cell::Cell = - const { std::cell::Cell::new(false) }; -} - -const CLOSURE_YOUNG_LOG_NAME: &str = "closure.dynamic_props"; - -/// Rule 1 of `gc/young_log.rs`: log `owner` BEFORE the entry is published -/// when the owner or the value being stored can matter to a minor. -#[inline] -fn note_young_closure_owner(owner: usize, value_bits: u64) { - if crate::gc::young_log::addr_is_minor_collectible(owner) - || crate::gc::young_log::bits_are_minor_relevant(value_bits) - { - #[cfg(test)] - if TEST_SUPPRESS_CLOSURE_YOUNG_NOTE.with(std::cell::Cell::get) { - return; - } - CLOSURE_YOUNG_OWNERS.with(|log| log.borrow_mut().note(owner)); - } -} - -#[cfg(test)] -mod young_log_sabotage_tests { - use super::*; - - #[test] - fn closure_log_rederivation_rejects_a_suppressed_setter() { - let _lock = crate::gc::global_side_table_test_lock(); - test_clear_closure_side_tables(); - let owner = crate::closure::js_closure_alloc(std::ptr::null(), 0) as usize; - TEST_SUPPRESS_CLOSURE_YOUNG_NOTE.with(|flag| flag.set(true)); - closure_set_dynamic_prop(owner, "sabotage", 7.0); - TEST_SUPPRESS_CLOSURE_YOUNG_NOTE.with(|flag| flag.set(false)); - let missed = std::panic::catch_unwind(debug_assert_closure_young_log_complete); - test_clear_closure_side_tables(); - assert!( - missed.is_err(), - "sabotage: suppressing closure_set_dynamic_prop's note must trip completeness" - ); - } -} - -/// A re-keyed entry keeps whatever values it had, so the new owner is logged -/// unconditionally; the next minor-scoped walk drops it if nothing in it is -/// relevant any more. -#[inline] -fn note_young_closure_owner_rekeyed(new_owner: usize) { - CLOSURE_YOUNG_OWNERS.with(|log| log.borrow_mut().note(new_owner)); -} - -per_test_global! { - /// #3655: keys deleted off a closure via `delete fn.name` etc. - /// - /// Functions carry built-in own data properties (`name`, `length`, and — - /// for constructors — `prototype`) that aren't stored in `CLOSURE_PROPS`: - /// they're synthesized from the arity/name registries on read. Those - /// properties are spec'd `configurable: true`, so `delete fn.name` must make - /// them disappear from every subsequent `hasOwnProperty` / `getOwnProperty*` - /// / value read. We can't remove a synthesized slot, so we record the - /// deletion here and have every property-protocol site consult it. test262's - /// `verifyProperty` exercises exactly this (delete-then-`hasOwnProperty`) - /// when checking `configurable`. - /// - /// Outer key is a closure address (`PtrHasher`); the inner `HashSet` - /// keeps SipHash for the same reason as `CLOSURE_PROPS`' inner map. - static CLOSURE_DELETED_KEYS: OnceLock>>> = - OnceLock::new(); -} - -fn get_closure_deleted_keys() -> &'static Mutex>> { - crate::once_init::get_or_init(&CLOSURE_DELETED_KEYS, || Mutex::new(new_ptr_hash_map())) -} - -/// Record that `key` was `delete`d off the closure at `ptr`. +/// Record that `key` was `delete`d off the closure at `ptr` — the #3655 +/// marker for a SYNTHESIZED own property (`name`, `length`, `prototype`, a +/// builtin static) that has no stored value to drop. Kept in the bag's +/// internal state record (`closure::props`). pub fn closure_mark_key_deleted(ptr: usize, key: &str) { - if ptr == 0 { + if ptr == 0 || !is_closure_ptr(ptr) { return; } - note_young_closure_owner(ptr, 0); - if let Ok(mut map) = get_closure_deleted_keys().lock() { - map.entry(ptr).or_default().insert(key.to_string()); - } + unsafe { super::props::state_mark_deleted(ptr, key) }; super::shape::note_function_own_state_changed(ptr); } /// True if `key` was previously `delete`d off the closure at `ptr`. pub fn closure_is_key_deleted(ptr: usize, key: &str) -> bool { - if ptr == 0 { + if ptr == 0 || !is_closure_ptr(ptr) { return false; } - get_closure_deleted_keys() - .lock() - .ok() - .map(|map| map.get(&ptr).map(|s| s.contains(key)).unwrap_or(false)) - .unwrap_or(false) + unsafe { super::props::state_is_deleted(ptr, key) } } /// True if `prop` is an OWN dynamic property of the closure at `ptr` (does NOT @@ -240,126 +75,38 @@ pub fn closure_is_key_deleted(ptr: usize, key: &str) -> bool { /// by `hasOwnProperty`/`getOwnPropertyNames` to report own user props and the /// constructor `prototype` slot without inheriting from a set prototype. pub fn closure_has_own_dynamic_prop(ptr: usize, prop: &str) -> bool { - get_closure_props() - .lock() - .ok() - .map(|m| m.get(&ptr).map(|p| p.contains_key(prop)).unwrap_or(false)) - .unwrap_or(false) -} - -per_test_global! { - /// #36 / #321: `Object.setPrototypeOf(closure, protoObj)` side-table. - /// - /// Maps a closure pointer to the NaN-box bits of the object that was set as - /// its static prototype. effect's `Context.Tag(id)` returns a plain function - /// `TagClass` whose `_op: "Tag"`, `[TagTypeId]`, and `[EffectTypeId]` live on - /// `TagProto` (a regular object), wired by `Object.setPrototypeOf(TagClass, - /// TagProto)`. Perry bakes class IDs at allocation time so it can't mutate a - /// real prototype chain, but recording the (closure → proto) link here lets - /// string- and symbol-keyed property reads on the closure walk to the proto's - /// own properties — so `TagClass._op === "Tag"` and `isTag(TagClass)` hold. - static CLOSURE_STATIC_PROTOTYPES: OnceLock>> = OnceLock::new(); -} - -fn get_closure_prototypes() -> &'static Mutex> { - crate::once_init::get_or_init( - &CLOSURE_STATIC_PROTOTYPES, - || Mutex::new(new_ptr_hash_map()), - ) + closure_get_own_dynamic_prop(ptr, prop).is_some() } /// Record `Object.setPrototypeOf(closure_ptr, proto)`. `proto_bits` is the /// NaN-box bits of the prototype object (POINTER-tagged). Idempotent overwrite. +/// +/// #36 / #321: effect's `Context.Tag(id)` wires `Object.setPrototypeOf(TagClass, +/// TagProto)`; recording the link lets string- and symbol-keyed reads on the +/// closure walk to the proto's own properties. The link lives in the bag's +/// internal state record, a traced edge of the closure. pub fn closure_set_static_prototype(closure_ptr: usize, proto_bits: u64) { - if closure_ptr == 0 { + if closure_ptr == 0 || !is_closure_ptr(closure_ptr) { return; } - let mut slot_addr = 0usize; - note_young_closure_owner(closure_ptr, proto_bits); - if let Ok(mut map) = get_closure_prototypes().lock() { - let slot = map.entry(closure_ptr).or_insert(0); - *slot = proto_bits; - slot_addr = slot as *mut u64 as usize; - } - if slot_addr != 0 { - crate::gc::runtime_write_barrier_external_slot(closure_ptr, slot_addr, proto_bits); - } + unsafe { super::props::state_set_prototype(closure_ptr, proto_bits) }; super::shape::note_function_own_state_changed(closure_ptr); } /// Look up the static prototype object bits recorded for a closure, if any. pub fn closure_static_prototype(closure_ptr: usize) -> Option { - get_closure_prototypes() - .lock() - .ok() - .and_then(|map| map.get(&closure_ptr).copied()) -} - -fn barrier_closure_dynamic_props(owner: usize, props: &mut ClosureProps) { - for value in props.values.values_mut() { - crate::gc::runtime_write_barrier_external_slot( - owner, - value as *mut f64 as usize, - value.to_bits(), - ); - } -} - -fn merge_closure_prop_map( - props: &mut PtrHashMap, - owner: usize, - owner_props: ClosureProps, -) { - match props.entry(owner) { - std::collections::hash_map::Entry::Occupied(mut entry) => { - entry.get_mut().merge_older(owner_props); - } - std::collections::hash_map::Entry::Vacant(entry) => { - entry.insert(owner_props); - } - } -} - -fn forwarded_heap_owner(owner: usize) -> Option { - if owner == 0 { + if closure_ptr == 0 || !is_closure_ptr(closure_ptr) { return None; } - if matches!( - crate::arena::classify_heap_generation(owner), - crate::arena::HeapGeneration::Unknown - ) { - return None; - } - unsafe { - let header = crate::value::addr_class::try_read_gc_header(owner)?; - if header.gc_flags & crate::gc::GC_FLAG_FORWARDED == 0 { - return None; - } - Some(crate::gc::forwarding_address(header as *const _) as usize) - } + unsafe { super::props::state_prototype(closure_ptr) } } -/// Dead-payload sweep arm (2026-07-09 GC audit wave 2): remove every side -/// table entry owned by the DEAD closure at `ptr`, exactly like -/// `object::clear_overflow_for_ptr` does for object overflow fields. Called -/// from `gc_type_clear_dead_payload_side_tables` when the sweep reclaims a -/// `GC_TYPE_CLOSURE` header — previously an explicit no-op, so one entry per -/// closure INSTANCE that ever got `fn.prop = …` / `setPrototypeOf(fn, …)` -/// (memoization wrappers, effect `Context.Tag`) leaked forever and a new -/// closure at the recycled address inherited the dead one's props. +/// Dead-payload sweep arm: release the wasm-host funcref handle owned by the +/// DEAD closure at `ptr`. Own properties need nothing — they die with it. pub(crate) fn clear_closure_side_tables_for_dead_ptr(ptr: usize) { if ptr == 0 { return; } - if let Ok(mut props) = get_closure_props().lock() { - props.remove(&ptr); - } - if let Ok(mut prototypes) = get_closure_prototypes().lock() { - prototypes.remove(&ptr); - } - if let Ok(mut deleted) = get_closure_deleted_keys().lock() { - deleted.remove(&ptr); - } #[cfg(feature = "wasm-host")] let external = get_wasm_funcref_externals() .lock() @@ -371,55 +118,25 @@ pub(crate) fn clear_closure_side_tables_for_dead_ptr(ptr: usize) { } } -/// Cheap sweep gate: true when any closure side table has -/// entries, so the per-dead-object `clear_dead_payload` dispatch can be -/// skipped entirely on the (overwhelmingly common) runs that never attach -/// props to closures. Mirrors `object::overflow_fields_is_empty`. +/// Cheap sweep gate: true when any closure-keyed side table has entries. pub(crate) fn closure_dynamic_side_tables_nonempty() -> bool { - let dynamic = get_closure_props().lock().is_ok_and(|m| !m.is_empty()) - || get_closure_prototypes().lock().is_ok_and(|m| !m.is_empty()) - || get_closure_deleted_keys() - .lock() - .is_ok_and(|m| !m.is_empty()); #[cfg(feature = "wasm-host")] - return dynamic - || get_wasm_funcref_externals() - .lock() - .is_ok_and(|m| !m.is_empty()); + return get_wasm_funcref_externals() + .lock() + .is_ok_and(|m| !m.is_empty()); #[cfg(not(feature = "wasm-host"))] - dynamic + false } -/// Death pruning for tenured/uncollected-by-sweep closures (2026-07-09 GC -/// audit wave 2): the sweep's dead-payload arm above only fires for headers -/// the ordinary sweep reclaims; closures dying in the ACTIVE nursery block, -/// in bulk block resets, or in copied-minor from-space never reach it. This -/// registry-style pass walks the tables with one of the GC's deadness -/// predicates (`gc::dead_owner`, narrowed to `GC_TYPE_CLOSURE`). The tables -/// are process-global: foreign threads' closure addresses don't attribute -/// and are skipped (documented residual). +/// Death pruning for closure-keyed tables (box-capture owners and the +/// wasm-host funcref table), with one of the GC's deadness predicates. pub(crate) fn prune_dead_closure_side_table_owners(is_dead_closure: &dyn Fn(usize) -> bool) { super::prune_dead_closure_box_capture_owners(is_dead_closure); - let mut verdicts: HashMap = HashMap::new(); - let mut is_dead = |owner: usize| -> bool { - *verdicts - .entry(owner) - .or_insert_with(|| is_dead_closure(owner)) - }; - if let Ok(mut props) = get_closure_props().lock() { - props.retain(|owner, _| !is_dead(*owner)); - } - if let Ok(mut prototypes) = get_closure_prototypes().lock() { - prototypes.retain(|owner, _| !is_dead(*owner)); - } - if let Ok(mut deleted) = get_closure_deleted_keys().lock() { - deleted.retain(|owner, _| !is_dead(*owner)); - } #[cfg(feature = "wasm-host")] let removed = if let Ok(mut externals) = get_wasm_funcref_externals().lock() { let mut removed = Vec::new(); externals.retain(|owner, handle| { - let keep = !is_dead(*owner); + let keep = !is_dead_closure(*owner); if !keep { removed.push(*handle); } @@ -436,101 +153,67 @@ pub(crate) fn prune_dead_closure_side_table_owners(is_dead_closure: &dyn Fn(usiz } /// Thread-heap teardown (#11319): drop every entry of the PROCESS-GLOBAL -/// closure side tables whose owner lies in one of `ranges` — the blocks an -/// exiting thread's arena is about to free. +/// closure side table (the wasm-host funcref table) whose owner lies in one +/// of `ranges` — the blocks an exiting thread's arena is about to free. /// -/// The tables outlive the thread that inserted an entry, but the young log -/// that names the entry is thread-local and dies with it, and the owner's +/// The table outlives the thread that inserted an entry, and the owner's /// memory goes back to the allocator. [`prune_dead_closure_side_table_owners`] /// cannot reach these entries (a foreign address does not attribute), so -/// without this they leak for the life of the process — and once another -/// thread's arena reuses the address range, a stale entry reads as THAT -/// thread's young owner: its minor's rule-2 re-derivation finds a relevant -/// key its log never noted (the young_log.rs rule-1 abort), and a new closure -/// allocated at the recycled address inherits the dead one's props. +/// without this they leak for the life of the process, and once another +/// thread's arena reuses the address range a stale entry would name a new +/// closure allocated there. A function's own properties are not here: they +/// live in the function object (`closure/props.rs`) and die with it. /// -/// Only the mutexed process-global tables are touched: this runs from a TLS -/// destructor, where the thread-local tables (young log, box captures) may -/// already be gone, and they die with the thread anyway. +/// Only the mutexed process-global table is touched: this runs from a TLS +/// destructor, where the thread-local tables (box captures) may already be +/// gone, and they die with the thread anyway. pub(crate) fn release_closure_side_table_owners_in_ranges(ranges: &[(usize, usize)]) { - if ranges.is_empty() { - return; - } - // Arena blocks never overlap, so a start-sorted list answers membership - // with one binary search per owner. - let mut ranges = ranges.to_vec(); - ranges.sort_unstable_by_key(|&(start, _)| start); - let in_ranges = |owner: usize| { - let after = ranges.partition_point(|&(start, _)| start <= owner); - after > 0 && owner < ranges[after - 1].1 - }; - if let Ok(mut props) = get_closure_props().lock() { - props.retain(|owner, _| !in_ranges(*owner)); - } - if let Ok(mut prototypes) = get_closure_prototypes().lock() { - prototypes.retain(|owner, _| !in_ranges(*owner)); - } - if let Ok(mut deleted) = get_closure_deleted_keys().lock() { - deleted.retain(|owner, _| !in_ranges(*owner)); - } - #[cfg(feature = "wasm-host")] - let removed = if let Ok(mut externals) = get_wasm_funcref_externals().lock() { - let mut removed = Vec::new(); - externals.retain(|owner, handle| { - let keep = !in_ranges(*owner); - if !keep { - removed.push(*handle); - } - keep - }); - removed - } else { - Vec::new() - }; + #[cfg(not(feature = "wasm-host"))] + let _ = ranges; #[cfg(feature = "wasm-host")] - for external in removed { - drop_wasm_funcref_external(external); + { + if ranges.is_empty() { + return; + } + // Arena blocks never overlap, so a start-sorted list answers + // membership with one binary search per owner. + let mut ranges = ranges.to_vec(); + ranges.sort_unstable_by_key(|&(start, _)| start); + let in_ranges = |owner: usize| { + let after = ranges.partition_point(|&(start, _)| start <= owner); + after > 0 && owner < ranges[after - 1].1 + }; + let removed = if let Ok(mut externals) = get_wasm_funcref_externals().lock() { + let mut removed = Vec::new(); + externals.retain(|owner, handle| { + let keep = !in_ranges(*owner); + if !keep { + removed.push(*handle); + } + keep + }); + removed + } else { + Vec::new() + }; + for external in removed { + drop_wasm_funcref_external(external); + } } } -/// [`prune_dead_closure_side_table_owners`] for a MINOR: only a young owner -/// can be dead, and a young owner is always in the young log (it was noted -/// at insert and is re-logged by every minor-scoped walk while it stays -/// young), so the log is the complete candidate set (#9754). +/// [`prune_dead_closure_side_table_owners`] for a MINOR. The wasm-host table +/// is tiny and only exists with that feature, so a minor walks it whole. pub(crate) fn prune_dead_closure_side_table_owners_young(is_dead_closure: &dyn Fn(usize) -> bool) { - super::prune_dead_closure_box_capture_owners(is_dead_closure); - let candidates = CLOSURE_YOUNG_OWNERS.with(|log| log.borrow_mut().take_sorted()); - let mut kept = Vec::with_capacity(candidates.len()); - for owner in candidates { - if is_dead_closure(owner) { - clear_closure_side_tables_for_dead_ptr(owner); - } else { - kept.push(owner); - } - } - CLOSURE_YOUNG_OWNERS.with(|log| log.borrow_mut().extend(kept)); + prune_dead_closure_side_table_owners(is_dead_closure); } +/// Owner-move hook (`GcMoveHookKind::ClosureDynamicProps`): re-key the +/// wasm-host funcref table. Own properties move with the closure. pub(crate) fn closure_dynamic_props_owner_moved(old_owner: usize, new_owner: usize) { if old_owner == 0 || new_owner == 0 || old_owner == new_owner { return; } - note_young_closure_owner_rekeyed(new_owner); - if let Ok(mut props) = get_closure_props().lock() { - if let Some(old_props) = props.remove(&old_owner) { - merge_closure_prop_map(&mut props, new_owner, old_props); - } - } - if let Ok(mut prototypes) = get_closure_prototypes().lock() { - if let Some(proto_bits) = prototypes.remove(&old_owner) { - prototypes.insert(new_owner, proto_bits); - } - } - if let Ok(mut deleted) = get_closure_deleted_keys().lock() { - if let Some(keys) = deleted.remove(&old_owner) { - deleted.entry(new_owner).or_default().extend(keys); - } - } #[cfg(feature = "wasm-host")] let replaced = get_wasm_funcref_externals() .lock() @@ -545,330 +228,24 @@ pub(crate) fn closure_dynamic_props_owner_moved(old_owner: usize, new_owner: usi } } -pub(crate) fn visit_closure_dynamic_prop_values_mut(owner: usize, mut visit: impl FnMut(&mut f64)) { - if owner == 0 { - return; - } - let Some(mut owner_props) = get_closure_props() - .lock() - .ok() - .and_then(|mut props| props.remove(&owner)) - else { - return; - }; - - for value in owner_props.values.values_mut() { - visit(value); - } - - // `visit` may rewrite an old value to a nursery/survivor address. This - // path temporarily removes the entry, so the ordinary setter's pre-publish - // young-log note does not cover the rewritten value. Re-arm the log before - // putting the entry back; otherwise the next minor-scoped root walk skips - // the new pointer (#11117). - let relevant_value_bits = owner_props - .values - .values() - .map(|value| value.to_bits()) - .find(|bits| crate::gc::young_log::bits_are_minor_relevant(*bits)) - .unwrap_or(0); - note_young_closure_owner(owner, relevant_value_bits); - if let Ok(mut props) = get_closure_props().lock() { - merge_closure_prop_map(&mut props, owner, owner_props); - } -} - -pub(crate) fn visit_closure_dynamic_prop_value_slots_mut( - owner: usize, - mut visit: impl FnMut(*mut u64), -) { - visit_closure_dynamic_prop_values_mut(owner, |value| { - visit(value as *mut f64 as *mut u64); - }); -} - -pub(crate) fn visit_closure_static_prototype_slot_mut( - owner: usize, - mut visit: impl FnMut(*mut u64), -) { - if owner == 0 { - return; - } - // Take the entry OUT and run the visit with the lock RELEASED: a - // copying-minor rewrite visitor can move the prototype closure, and - // move fixup re-enters `closure_dynamic_props_owner_moved`, which - // takes this same lock — visiting under it self-deadlocks the - // collector (the geisterhand+reviver GC test wedged CI's cargo-test - // at the 3h job timeout). Same remove → visit → merge-back pattern - // as `visit_closure_dynamic_prop_values_mut` above and the roots - // scanner below. - let Some(mut proto_bits) = get_closure_prototypes() - .lock() - .ok() - .and_then(|mut prototypes| prototypes.remove(&owner)) - else { - return; - }; - visit(&mut proto_bits as *mut u64); - // The visit can forward the owner itself (self-referential - // prototype); re-key like the roots scanner does. - let new_owner = forwarded_heap_owner(owner).unwrap_or(owner); - note_young_closure_owner(new_owner, proto_bits); - if let Ok(mut prototypes) = get_closure_prototypes().lock() { - prototypes.insert(new_owner, proto_bits); - } -} - -fn closure_side_table_owners() -> Vec { - let mut owners: Vec = Vec::new(); - if let Ok(props) = get_closure_props().lock() { - owners.extend(props.keys().copied()); - } - if let Ok(prototypes) = get_closure_prototypes().lock() { - owners.extend(prototypes.keys().copied()); - } - if let Ok(deleted) = get_closure_deleted_keys().lock() { - owners.extend(deleted.keys().copied()); - } - #[cfg(feature = "wasm-host")] - if let Ok(externals) = get_wasm_funcref_externals().lock() { - owners.extend(externals.keys().copied()); - } - owners.sort_unstable(); - owners.dedup(); - owners -} - -/// Mutable GC scanner for closure dynamic-property side-table metadata. -/// -/// The side table is keyed by closure address. The key itself is metadata -/// (visited only so a moved closure has its entry re-keyed; the metadata -/// visitor is a no-op in mark phases), but the **values** are real JS -/// references that must be marked alive in every phase, just like the -/// parallel `scan_overflow_fields_roots_mut` (`object/mod.rs`) does for -/// object overflow fields. #1802: pre-fix this scanner early-returned -/// unless `is_metadata_rewrite_phase()`, so during `Mark` / -/// `CopyingMark` the values were never traced, and a closure prop whose -/// transitive contents were reachable only via the side table (e.g. -/// ajv's `validate.errors = [{ msg }]`) had its element objects freed -/// behind the still-live array. -/// -/// #9754: a minor-scoped pass (`visitor.young_scope()`) visits only the -/// owners in `CLOSURE_YOUNG_OWNERS`; a full pass walks every owner and -/// rebuilds the log. Both go through [`scan_closure_owner`], so the per-entry -/// work is identical and only the candidate set differs. +/// Mutable GC scanner for the wasm-host funcref table: its keys are +/// metadata (re-keyed when a closure moves, never a root). pub fn scan_closure_dynamic_props_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { - if visitor.young_scope() { - scan_closure_side_tables_young(visitor); - return; - } - let owners = closure_side_table_owners(); - let table_len = owners.len() as u64; - // A full walk is authoritative: rebuild the log from what it finds. - // Notes made by owner-move hooks while the walk runs land in the emptied - // log and are kept — they name entries this walk already visited under - // their old key, so the duplicate is harmless. - let _ = CLOSURE_YOUNG_OWNERS.with(|log| log.borrow_mut().take_sorted()); - let mut kept = CLOSURE_YOUNG_OWNERS.with(|log| log.borrow_mut().take_spare()); - for owner in owners { - let (new_owner, relevant) = scan_closure_owner(visitor, owner); - if relevant { - kept.push(new_owner); - } - } - let kept_len = kept.len() as u64; - CLOSURE_YOUNG_OWNERS.with(|log| log.borrow_mut().extend(kept)); - crate::gc::young_log::note_walk( - CLOSURE_YOUNG_LOG_NAME, - crate::gc::young_log::YoungLogWalk { - partial: false, - logged: table_len, - visited: table_len, - kept: kept_len, - table_len, - }, - ); -} - -/// The minor-scoped walk: only logged owners. Rounds repeat while visits -/// trigger owner-move hooks that log new keys (`note_young_closure_owner_rekeyed`), -/// which is also what closes the pre-#9754 gap where an entry re-keyed -/// mid-walk was skipped by the mark pass and only rewritten later. -fn scan_closure_side_tables_young(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { - // Count the same distinct owners as the full walk, not map memberships. - // Enumerating them is diagnostic work: ordinary minor collections must - // retain the young log's ability to skip the full owner population. - let table_len = (cfg!(test) || crate::gc::gc_diag_enabled()) - .then(|| closure_side_table_owners().len() as u64); - #[cfg(any(debug_assertions, test))] - debug_assert_closure_young_log_complete(); - let mut logged = 0u64; - let mut visited = 0u64; - let mut kept = CLOSURE_YOUNG_OWNERS.with(|log| log.borrow_mut().take_spare()); - loop { - let batch = CLOSURE_YOUNG_OWNERS.with(|log| log.borrow_mut().take_sorted()); - if batch.is_empty() { - break; - } - logged += batch.len() as u64; - for owner in batch { - visited += 1; - let (new_owner, relevant) = scan_closure_owner(visitor, owner); - if relevant { - kept.push(new_owner); - } - } - } - let kept_len = kept.len() as u64; - CLOSURE_YOUNG_OWNERS.with(|log| log.borrow_mut().extend(kept)); - if let Some(table_len) = table_len { - crate::gc::young_log::note_walk( - CLOSURE_YOUNG_LOG_NAME, - crate::gc::young_log::YoungLogWalk { - partial: true, - logged, - visited, - kept: kept_len, - table_len, - }, - ); - } -} - -/// Rule 2 of `gc/young_log.rs`: re-derive the relevant owners from the three -/// tables and require the log to name each one. -#[cfg(any(debug_assertions, test))] -fn debug_assert_closure_young_log_complete() { - use crate::gc::young_log::{addr_is_minor_collectible, bits_are_minor_relevant}; - let mut relevant = Vec::new(); - if let Ok(props) = get_closure_props().lock() { - for (&owner, entry) in props.iter() { - if addr_is_minor_collectible(owner) - || entry - .values - .values() - .any(|value| bits_are_minor_relevant(value.to_bits())) - { - relevant.push(owner); - } - } - } - if let Ok(prototypes) = get_closure_prototypes().lock() { - for (&owner, &proto_bits) in prototypes.iter() { - if addr_is_minor_collectible(owner) || bits_are_minor_relevant(proto_bits) { - relevant.push(owner); - } - } - } - if let Ok(deleted) = get_closure_deleted_keys().lock() { - for &owner in deleted.keys() { - if addr_is_minor_collectible(owner) { - relevant.push(owner); - } - } - } #[cfg(feature = "wasm-host")] - if let Ok(externals) = get_wasm_funcref_externals().lock() { - for &owner in externals.keys() { - if addr_is_minor_collectible(owner) { - relevant.push(owner); - } - } - } - CLOSURE_YOUNG_OWNERS.with(|log| { - log.borrow() - .debug_assert_logged(CLOSURE_YOUNG_LOG_NAME, &relevant) - }); -} - -/// Visit one owner's entries in all three tables — the per-entry body both -/// walks share. Returns the owner's post-visit key and whether the entry can -/// still matter to a minor (its key or any value is not old). -fn scan_closure_owner( - visitor: &mut crate::gc::RuntimeRootVisitor<'_>, - owner: usize, -) -> (usize, bool) { - use crate::gc::young_log::{addr_is_minor_collectible, bits_are_minor_relevant}; - let mut relevant = false; - let mut current_owner = owner; - - if let Some(mut closure_props) = get_closure_props() - .lock() - .ok() - .and_then(|mut props| props.remove(&owner)) - { - // Metadata key rewrite. Only fires in rewrite-phase modes; mark phases - // return `false` here without recording the key as a root (so the - // side-table entry doesn't itself keep the closure alive). - let mut new_owner = owner; - visitor.visit_metadata_usize_slot(&mut new_owner); - // #1802: trace every stored value in every phase. In `Mark` / - // `CopyingMark` this keeps `fn.errors = [...]` and its transitive - // contents reachable; in rewrite phases it updates slot bits when a - // value was forwarded. - for value in closure_props.values.values_mut() { - visitor.visit_nanbox_f64_slot(value); - relevant |= bits_are_minor_relevant(value.to_bits()); - } - if new_owner == owner { - new_owner = forwarded_heap_owner(owner).unwrap_or(owner); - } - current_owner = new_owner; - if let Ok(mut props) = get_closure_props().lock() { - merge_closure_prop_map(&mut props, new_owner, closure_props); - } - } - - if let Some(mut proto_bits) = get_closure_prototypes() - .lock() - .ok() - .and_then(|mut prototypes| prototypes.remove(&owner)) { - let mut new_owner = owner; - visitor.visit_metadata_usize_slot(&mut new_owner); - visitor.visit_nanbox_u64_slot(&mut proto_bits); - relevant |= bits_are_minor_relevant(proto_bits); - if new_owner == owner { - new_owner = forwarded_heap_owner(owner).unwrap_or(owner); - } - current_owner = new_owner; - if let Ok(mut prototypes) = get_closure_prototypes().lock() { - prototypes.insert(new_owner, proto_bits); - } - } - - // #3655: re-key the deleted-keys side table when a closure moves. The - // entries are pure metadata (string keys, no JS references), so the - // metadata-key visitor only records a re-key; nothing to trace. - if let Ok(mut deleted) = get_closure_deleted_keys().lock() { - if deleted.contains_key(&owner) { - let mut new_owner = owner; - if visitor.visit_metadata_usize_slot(&mut new_owner) && new_owner != owner { - if let Some(keys) = deleted.remove(&owner) { - deleted.entry(new_owner).or_default().extend(keys); - } - current_owner = new_owner; - } - } - } - - #[cfg(feature = "wasm-host")] - if let Ok(mut externals) = get_wasm_funcref_externals().lock() { - if externals.contains_key(&owner) { + let owners: Vec = get_wasm_funcref_externals() + .lock() + .map(|m| m.keys().copied().collect()) + .unwrap_or_default(); + for owner in owners { let mut new_owner = owner; if visitor.visit_metadata_usize_slot(&mut new_owner) && new_owner != owner { - if let Some(handle) = externals.remove(&owner) { - if let Some(replaced) = externals.insert(new_owner, handle) { - drop_wasm_funcref_external(replaced); - } - } - current_owner = new_owner; + closure_dynamic_props_owner_moved(owner, new_owner); } } } - - relevant |= addr_is_minor_collectible(current_owner); - (current_owner, relevant) + #[cfg(not(feature = "wasm-host"))] + let _ = visitor; } /// Is `ptr` a live function object (`GC_TYPE_CLOSURE` cell)? Safe for an @@ -990,12 +367,8 @@ pub fn closure_get_dynamic_prop(ptr: usize, prop: &str) -> f64 { return result; } - if let Ok(props) = get_closure_props().lock() { - if let Some(closure_props) = props.get(&ptr) { - if let Some(&val) = closure_props.get(prop) { - return val; - } - } + if let Some(val) = closure_get_own_dynamic_prop(ptr, prop) { + return val; } // #11175: resolve these inherited values from the actual prototype, // including explicit null/custom chains. Do this before the legacy walk @@ -1075,10 +448,8 @@ pub fn closure_get_dynamic_prop(ptr: usize, prop: &str) -> f64 { crate::object::js_implicit_this_set(prev.get_nanbox_f64()); return result; } - if let Ok(props) = get_closure_props().lock() { - if let Some(p) = props.get(&proto_ptr).and_then(|m| m.get(prop)) { - return *p; - } + if let Some(p) = closure_get_own_dynamic_prop(proto_ptr, prop) { + return p; } cur = proto_ptr; depth += 1; @@ -1267,61 +638,43 @@ pub(crate) fn closure_set_via_function_prototype_descriptor( false } -/// Set a dynamic property on a closure. +/// Set a dynamic property on a closure (an own data property in its bag). +/// A non-closure `ptr` is ignored. pub fn closure_set_dynamic_prop(ptr: usize, prop: &str, value: f64) { - if !super::shape::is_intrinsic_function_key(prop) { - super::shape::note_function_own_state_changed(ptr); - } - note_young_closure_owner(ptr, value.to_bits()); - if let Ok(mut props) = get_closure_props().lock() { - let closure_props = props.entry(ptr).or_default(); - closure_props.insert(prop.to_string(), value); - barrier_closure_dynamic_props(ptr, closure_props); + if ptr == 0 || !is_closure_ptr(ptr) { + return; } + unsafe { super::props::bag_set(ptr, prop, value) }; // #3655: re-defining a previously deleted slot makes it present again. - if let Ok(mut deleted) = get_closure_deleted_keys().lock() { - if let Some(keys) = deleted.get_mut(&ptr) { - keys.remove(prop); - } - } + unsafe { super::props::state_clear_deleted(ptr, prop) }; + super::shape::refresh_closure_shape(ptr); } /// Read an OWN dynamic property without any prototype/builtin fallback. /// Used by `bind` to honor an `Object.defineProperty(fn, "length", …)` /// override before falling back to the registered declared length. pub fn closure_get_own_dynamic_prop(ptr: usize, prop: &str) -> Option { - if let Ok(props) = get_closure_props().lock() { - return props.get(&ptr).and_then(|m| m.get(prop).copied()); + if ptr == 0 || !is_closure_ptr(ptr) { + return None; } - None + unsafe { super::props::bag_get(ptr, prop.as_bytes()) } } /// #3655: remove an OWN user dynamic property from a closure (used by /// `delete fn.userProp`). Returns true if a property was actually removed. /// Built-in synthesized slots (`name`/`length`/`prototype`) are handled by -/// `closure_mark_key_deleted` instead, since they have no map entry to drop. +/// `closure_mark_key_deleted` instead, since they have no stored value. pub fn closure_delete_own_dynamic_prop(ptr: usize, prop: &str) -> bool { - super::shape::note_function_own_state_changed(ptr); - if let Ok(mut props) = get_closure_props().lock() { - if let Some(closure_props) = props.get_mut(&ptr) { - return closure_props.remove(prop).is_some(); - } + if ptr == 0 || !is_closure_ptr(ptr) { + return false; } - false + let removed = unsafe { super::props::bag_remove(ptr, prop) }; + super::shape::refresh_closure_shape(ptr); + removed } #[cfg(test)] pub(crate) fn test_clear_closure_side_tables() { - CLOSURE_YOUNG_OWNERS.with(|log| log.borrow_mut().clear()); - if let Ok(mut props) = get_closure_props().lock() { - props.clear(); - } - if let Ok(mut prototypes) = get_closure_prototypes().lock() { - prototypes.clear(); - } - if let Ok(mut deleted) = get_closure_deleted_keys().lock() { - deleted.clear(); - } #[cfg(feature = "wasm-host")] let externals = get_wasm_funcref_externals() .lock() @@ -1344,45 +697,10 @@ pub(crate) fn test_clear_closure_side_tables() { /// by `format_jsvalue` to emit `[Function: f] { ownProp: value }`. See #1203 /// and #9148. pub fn closure_dynamic_props_snapshot(ptr: usize) -> Vec<(String, f64)> { - if let Ok(props) = get_closure_props().lock() { - if let Some(map) = props.get(&ptr) { - return map.snapshot(); - } - } - Vec::new() -} - -#[cfg(test)] -mod tests_9148 { - use super::ClosureProps; - - fn names(props: &ClosureProps) -> Vec { - props.snapshot().into_iter().map(|(name, _)| name).collect() - } - - #[test] - fn closure_props_snapshot_uses_ecma_own_key_order() { - let mut props = ClosureProps::default(); - props.insert("tag".to_string(), 1.0); - props.insert("other".to_string(), 2.0); - props.insert("10".to_string(), 10.0); - props.insert("2".to_string(), 2.0); - - assert_eq!(names(&props), ["2", "10", "tag", "other"]); - } - - #[test] - fn update_keeps_position_and_delete_readd_moves_to_tail() { - let mut props = ClosureProps::default(); - props.insert("tag".to_string(), 1.0); - props.insert("other".to_string(), 2.0); - props.insert("tag".to_string(), 3.0); - assert_eq!(names(&props), ["tag", "other"]); - - assert_eq!(props.remove("tag"), Some(3.0)); - props.insert("tag".to_string(), 4.0); - assert_eq!(names(&props), ["other", "tag"]); + if ptr == 0 || !is_closure_ptr(ptr) { + return Vec::new(); } + unsafe { super::props::bag_snapshot(ptr) } } /// Unbind `this` from a detached method closure. @@ -1467,106 +785,6 @@ mod tests_1802 { .unwrap_or_else(|poisoned| poisoned.into_inner()) } - /// #1802: the side-table values must be visited in mark phases, not - /// only during the metadata-rewrite tail. Pre-fix - /// `scan_closure_dynamic_props_roots_mut` early-returned unless - /// `is_metadata_rewrite_phase()`, so the `for_copy` adapter (which - /// wraps a non-rewrite callback) saw nothing — proving the values - /// were never traced in `Mark` / `CopyingMark`. With the early-return - /// removed, the adapter sees every stored value's bits. - #[test] - fn dyn_prop_values_are_visited_in_mark_phase() { - // CLOSURE_PROPS is PROCESS-global; the gc test guards' state reset - // (`test_clear_closure_side_tables`) clears it from parallel test - // threads, wiping this test's parked entry mid-assertion. Serialize - // against those guards, THEN against this module's own tests. - let _global = crate::gc::global_side_table_test_lock(); - let _guard = side_table_test_lock(); - // A unique synthetic closure address (just an integer key — the - // scanner doesn't deref it during value visitation; the - // metadata-key visitor is a no-op for non-heap addresses). - let owner: usize = 0xC10C_AB1E_0000_1802; - let value_bits: u64 = 0x7FFD_AAAA_BBBB_CCCC; - closure_set_dynamic_prop(owner, "errors", f64::from_bits(value_bits)); - - // Copy-mode visitor calls our closure for every nanbox-bits - // slot the scanner visits. Pre-fix this produced an empty - // `seen` vec because the scanner early-returned. - let mut seen: Vec = Vec::new(); - { - let mut mark = |v: f64| seen.push(v.to_bits()); - let mut visitor = crate::gc::RuntimeRootVisitor::for_copy(&mut mark); - scan_closure_dynamic_props_roots_mut(&mut visitor); - } - - assert!( - seen.contains(&value_bits), - "expected stored prop value bits {:x} in seen={:x?} — \ - scanner did not trace the value during the mark phase", - value_bits, - seen, - ); - - // Cleanup so other tests don't see the synthetic entry. - if let Ok(mut props) = get_closure_props().lock() { - props.remove(&owner); - } - } - - #[test] - fn dyn_prop_scanner_visits_values_without_holding_props_lock() { - // CLOSURE_PROPS is PROCESS-global; the gc test guards' state reset - // (`test_clear_closure_side_tables`) clears it from parallel test - // threads, wiping this test's parked entry mid-assertion. Serialize - // against those guards, THEN against this module's own tests. - let _global = crate::gc::global_side_table_test_lock(); - let _guard = side_table_test_lock(); - let owner: usize = 0xC10C_AB1E_0000_1803; - let value_bits: u64 = 0x7FFD_AAAA_BBBB_CCCD; - closure_set_dynamic_prop(owner, "errors", f64::from_bits(value_bits)); - - let mut saw_value = false; - let mut lock_was_free = false; - { - let mut mark = |v: f64| { - if v.to_bits() == value_bits { - saw_value = true; - // The regression under test is the SCANNER holding - // CLOSURE_PROPS across visitor callbacks — a same-thread - // hold, so `try_lock` can never succeed no matter how - // long we wait. A one-shot `try_lock` also fails on - // transient contention from an unrelated parallel test - // thread's brief map access (#6965) — retry with a yield - // so a foreign holder gets to release. `Poisoned` counts - // as free: poison means a panicking holder already - // RELEASED the mutex. - lock_was_free = (0..4096).any(|_| match get_closure_props().try_lock() { - Ok(_) | Err(std::sync::TryLockError::Poisoned(_)) => true, - Err(std::sync::TryLockError::WouldBlock) => { - std::thread::yield_now(); - false - } - }); - } - }; - let mut visitor = crate::gc::RuntimeRootVisitor::for_copy(&mut mark); - scan_closure_dynamic_props_roots_mut(&mut visitor); - } - - assert!( - saw_value, - "scanner did not visit the stored closure prop value" - ); - assert!( - lock_was_free, - "scanner must not hold CLOSURE_PROPS while visitor callbacks can move closures" - ); - - if let Ok(mut props) = get_closure_props().lock() { - props.remove(&owner); - } - } - #[test] fn dyn_prop_get_ignores_non_closure_receivers() { // CLOSURE_PROPS is PROCESS-global; the gc test guards' state reset @@ -1731,23 +949,18 @@ pub(crate) fn clone_closure_rebind_this(closure_bits: u64, recv_box: f64) -> u64 } } -/// `PERRY_GC_CENSUS`: closure dynamic-property side tables. +/// `PERRY_GC_CENSUS`: closure-keyed side tables (own properties are object +/// storage now and are counted with the heap). pub(super) fn dynamic_props_census() -> Vec { - use crate::gc::census::{map_bytes, set_bytes}; + #[allow(unused_mut)] let mut rows = Vec::new(); - if let Ok(m) = get_closure_props().lock() { - let inner: usize = m - .values() - .map(|p| map_bytes(&p.values) + p.values.keys().map(|k| k.capacity()).sum::()) - .sum(); - rows.push(("closure.dynamic_props", m.len(), map_bytes(&m) + inner)); - } - if let Ok(m) = get_closure_deleted_keys().lock() { - let inner: usize = m.values().map(set_bytes).sum(); - rows.push(("closure.deleted_keys", m.len(), map_bytes(&m) + inner)); - } - if let Ok(m) = get_closure_prototypes().lock() { - rows.push(("closure.static_prototypes", m.len(), map_bytes(&m))); + #[cfg(feature = "wasm-host")] + if let Ok(m) = get_wasm_funcref_externals().lock() { + rows.push(( + "closure.wasm_funcref_externals", + m.len(), + crate::gc::census::map_bytes(&m), + )); } rows } diff --git a/crates/perry-runtime/src/closure/mod.rs b/crates/perry-runtime/src/closure/mod.rs index 3a9034f2b9..7dac269eb0 100644 --- a/crates/perry-runtime/src/closure/mod.rs +++ b/crates/perry-runtime/src/closure/mod.rs @@ -9,6 +9,7 @@ mod alloc; mod box_captures; mod dispatch; mod dynamic_props; +pub(crate) mod props; mod registry; pub(crate) mod shape; mod unbox; @@ -95,7 +96,6 @@ pub(crate) use dynamic_props::{ closure_set_via_function_prototype_descriptor, function_prototype_fallback_target, function_prototype_inherited_get, prune_dead_closure_side_table_owners, prune_dead_closure_side_table_owners_young, release_closure_side_table_owners_in_ranges, - visit_closure_dynamic_prop_value_slots_mut, visit_closure_static_prototype_slot_mut, }; pub use dynamic_props::{ closure_delete_own_dynamic_prop, closure_dynamic_props_snapshot, closure_get_dynamic_prop, diff --git a/crates/perry-runtime/src/closure/props.rs b/crates/perry-runtime/src/closure/props.rs new file mode 100644 index 0000000000..37c3257344 --- /dev/null +++ b/crates/perry-runtime/src/closure/props.rs @@ -0,0 +1,265 @@ +//! A function object's own properties, stored IN the function object (D1). +//! +//! `ClosureHeader::props` points at the function's BAG: a runtime-internal, +//! null-prototype `ObjectHeader` whose keys and slots are the function's own +//! string-keyed data properties in ordinary creation order. It is created on +//! the first own-property write and is a traced, rewritten raw-pointer child +//! edge of the closure (`gc::layout`'s `ClosureCaptures` arm), so it moves +//! and dies with the function — no address-keyed table, no re-key hook, no +//! dead-owner prune, no young log. +//! +//! The bag's own `ObjectMeta.expando` (an ordinary child edge of the bag) +//! holds the function's rare internal STATE record, another null-prototype +//! object: the #3655 deleted-synthesized-key markers (`"d:" + key`) and the +//! recorded `[[Prototype]]` (`"p"`). Its keys are namespaced, and it is never +//! reachable from JS. +//! +//! Writers run under `GcSuppressScope`: the ~100 callers of +//! `closure_set_dynamic_prop` hold raw closure addresses across the call, as +//! they always could when the store was a Rust `HashMap`, so the allocations +//! here (bag, key string, state record) must not move anything. +use super::ClosureHeader; +use crate::object::ObjectHeader; +use crate::value::JSValue; + +const STATE_PROTO: &str = "p"; +const DELETED_PREFIX: &str = "d:"; + +/// The bag of the closure at `ptr` (null when it never had an own property). +/// +/// # Safety +/// `ptr` is a proven, live closure cell. +#[inline] +pub(crate) unsafe fn bag_of(ptr: usize) -> *mut ObjectHeader { + (*(ptr as *const ClosureHeader)).props +} + +/// Allocate the bag if absent and install it with the store barrier. +unsafe fn bag_ensure(ptr: usize) -> *mut ObjectHeader { + let existing = bag_of(ptr); + if !existing.is_null() { + return existing; + } + let bag = crate::object::js_object_alloc_null_proto(0, 0); + let closure = ptr as *mut ClosureHeader; + // A closure is born `GC_LAYOUT_POINTER_FREE` when its captures hold no + // pointer; some collector paths treat that state as "no child edge at + // all". It now has one, so it leaves that state (#7630's tag-checked + // `UNKNOWN`, the state a pointer capture store would also produce). + crate::gc::layout_note_closure_edge_installed(ptr as *mut u8); + // GC_STORE_AUDIT(BARRIERED): header raw-pointer edge store + object-slot + // barrier, mirroring `object_meta_ensure`'s `meta` install. + (*closure).props = bag; + crate::gc::runtime_write_barrier_slot(ptr, &(*closure).props as *const _ as usize, bag as u64); + bag +} + +/// Own data lookup in an ordinary (or dictionary-mode) bag by key bytes. +unsafe fn object_own_get(obj: *const ObjectHeader, key: &[u8]) -> Option { + let keys = crate::object::object_keys(obj); + let arr = keys.arr(); + if arr.is_null() { + return None; + } + let slot = crate::object::keys_find_slot_by_bytes_resolved(arr, keys.count(), key)?; + let live = crate::object::object_live_slot_count(obj); + let value = crate::object::object_field_at_with_live(obj, slot, live); + if value.bits() == crate::value::TAG_HOLE { + return None; + } + Some(f64::from_bits(value.bits())) +} + +/// The function's own data property `key`, if present. +/// +/// # Safety +/// `ptr` is a proven, live closure cell. +pub(crate) unsafe fn bag_get(ptr: usize, key: &[u8]) -> Option { + let bag = bag_of(ptr); + if bag.is_null() { + return None; + } + object_own_get(bag, key) +} + +unsafe fn object_own_set(obj: *mut ObjectHeader, key: &str, value: f64) { + let key = crate::string::js_string_from_bytes(key.as_ptr(), key.len() as u32); + crate::object::js_object_set_field_by_name(obj, key, value); +} + +/// Define/overwrite the function's own data property `key` (plain `[[Set]]` +/// on the null-prototype bag: no inherited setter can run). +/// +/// # Safety +/// `ptr` is a proven, live closure cell. +pub(crate) unsafe fn bag_set(ptr: usize, key: &str, value: f64) { + let _no_move = crate::gc::GcSuppressScope::new(); + let bag = bag_ensure(ptr); + object_own_set(bag, key, value); +} + +/// Remove the function's own data property `key`; true when it existed. +/// +/// # Safety +/// `ptr` is a proven, live closure cell. +pub(crate) unsafe fn bag_remove(ptr: usize, key: &str) -> bool { + let bag = bag_of(ptr); + if bag.is_null() || object_own_get(bag, key.as_bytes()).is_none() { + return false; + } + let _no_move = crate::gc::GcSuppressScope::new(); + let key_hdr = crate::string::js_string_from_bytes(key.as_ptr(), key.len() as u32); + crate::object::js_object_delete_field(bag, key_hdr); + true +} + +/// Every own data property in ECMA-262 own-key order: integer indices +/// ascending, then other strings in creation order. +/// +/// # Safety +/// `ptr` is a proven, live closure cell. +pub(crate) unsafe fn bag_snapshot(ptr: usize) -> Vec<(String, f64)> { + let bag = bag_of(ptr); + if bag.is_null() { + return Vec::new(); + } + let keys = crate::object::object_keys(bag); + let arr = keys.arr(); + if arr.is_null() { + return Vec::new(); + } + let live = crate::object::object_live_slot_count(bag); + let mut indexed: Vec<(u32, String, f64)> = Vec::new(); + let mut strings: Vec<(String, f64)> = Vec::new(); + for i in 0..keys.count() { + let value = crate::object::object_field_at_with_live(bag, i, live); + if value.bits() == crate::value::TAG_HOLE { + continue; + } + let key = JSValue::from_bits(crate::array::js_array_get_f64(arr, i).to_bits()); + let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let Some(bytes) = crate::string::js_string_key_bytes(key, &mut scratch) else { + continue; + }; + let name = String::from_utf8_lossy(bytes).into_owned(); + let v = f64::from_bits(value.bits()); + match crate::object::canonical_array_index(&name) { + Some(index) => indexed.push((index, name, v)), + None => strings.push((name, v)), + } + } + crate::cold_sort::sort_by_key(&mut indexed, |(index, _, _)| *index); + indexed + .into_iter() + .map(|(_, key, value)| (key, value)) + .chain(strings) + .collect() +} + +/// The closure's internal state record (null when none). +unsafe fn state_of(ptr: usize) -> *mut ObjectHeader { + let bag = bag_of(ptr); + if bag.is_null() { + return std::ptr::null_mut(); + } + crate::object::cell_expando_get(bag as usize).unwrap_or(std::ptr::null_mut()) +} + +unsafe fn state_ensure(ptr: usize) -> Option<*mut ObjectHeader> { + let bag = bag_ensure(ptr); + let existing = state_of(ptr); + if !existing.is_null() { + return Some(existing); + } + // A null-prototype record (its `"p"` key is not a JS property, and no + // inherited setter may run on it), hung off the bag's `meta.expando` — + // an ordinary traced edge of the bag. + let meta = crate::object::object_meta_ensure(bag); + if meta.is_null() { + return None; + } + let state = crate::object::js_object_alloc_null_proto(0, 0); + let meta = (*bag).meta; + let boxed = crate::value::js_nanbox_pointer(state 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(state) +} + +/// Is the #3655 deleted marker for `key` set on the closure at `ptr`? +/// +/// # Safety +/// `ptr` is a proven, live closure cell. +pub(crate) unsafe fn state_is_deleted(ptr: usize, key: &str) -> bool { + let state = state_of(ptr); + if state.is_null() { + return false; + } + let mut marker = String::with_capacity(DELETED_PREFIX.len() + key.len()); + marker.push_str(DELETED_PREFIX); + marker.push_str(key); + object_own_get(state, marker.as_bytes()).is_some() +} + +/// # Safety +/// `ptr` is a proven, live closure cell. +pub(crate) unsafe fn state_mark_deleted(ptr: usize, key: &str) { + let _no_move = crate::gc::GcSuppressScope::new(); + let Some(state) = state_ensure(ptr) else { + return; + }; + object_own_set( + state, + &format!("{DELETED_PREFIX}{key}"), + f64::from_bits(crate::value::TAG_TRUE), + ); +} + +/// # Safety +/// `ptr` is a proven, live closure cell. +pub(crate) unsafe fn state_clear_deleted(ptr: usize, key: &str) { + if !state_is_deleted(ptr, key) { + return; + } + let _no_move = crate::gc::GcSuppressScope::new(); + let state = state_of(ptr); + let marker = format!("{DELETED_PREFIX}{key}"); + let key_hdr = crate::string::js_string_from_bytes(marker.as_ptr(), marker.len() as u32); + crate::object::js_object_delete_field(state, key_hdr); +} + +/// The recorded `[[Prototype]]` bits, if any. +/// +/// # Safety +/// `ptr` is a proven, live closure cell. +pub(crate) unsafe fn state_prototype(ptr: usize) -> Option { + let state = state_of(ptr); + if state.is_null() { + return None; + } + object_own_get(state, STATE_PROTO.as_bytes()).map(f64::to_bits) +} + +/// # Safety +/// `ptr` is a proven, live closure cell. +pub(crate) unsafe fn state_set_prototype(ptr: usize, proto_bits: u64) { + let _no_move = crate::gc::GcSuppressScope::new(); + let Some(state) = state_ensure(ptr) else { + return; + }; + object_own_set(state, STATE_PROTO, f64::from_bits(proto_bits)); +} + +/// True when the closure carries internal state a base/keyed Function shape +/// cannot describe (a deleted marker or a recorded prototype). +/// +/// # Safety +/// `ptr` is a proven, live closure cell. +pub(crate) unsafe fn has_state(ptr: usize) -> bool { + !state_of(ptr).is_null() +} diff --git a/crates/perry-runtime/src/closure/shape.rs b/crates/perry-runtime/src/closure/shape.rs index 0744623b68..21c2713b9d 100644 --- a/crates/perry-runtime/src/closure/shape.rs +++ b/crates/perry-runtime/src/closure/shape.rs @@ -201,7 +201,9 @@ pub(crate) fn forget_body_classification(func_ptr: *const u8) { }); } -/// Is `closure` on a base Function shape (any body kind)? +/// Is `closure` on a DESCRIBED Function shape (base or keyed, any body +/// kind) — i.e. not FunctionDictionary? Such a closure has no accessor, no +/// symbol key, no delete marker and no recorded prototype. /// /// # Safety /// `closure` is a proven, live closure cell. @@ -234,16 +236,100 @@ pub(crate) unsafe fn closure_become_dictionary(closure: *mut ClosureHeader) { } } -/// The intrinsic own properties a base Function shape stands for. -#[inline] -pub(crate) fn is_intrinsic_function_key(key: &str) -> bool { - is_intrinsic_function_key_bytes(key.as_bytes()) +/// Recompute the closure's ShapeId from its own-property bag after a string +/// key was added or removed: the base Function shape of its body kind while +/// the bag is empty; a KEYED Function shape (the bag's keys, count and inline +/// bound, this body kind's prototype) while the bag is an ordinary tombstone- +/// free object; FunctionDictionary otherwise. FunctionDictionary is sticky — +/// it also records facts the bag cannot show (an accessor, a symbol key, a +/// recorded prototype, a delete marker). +/// +/// Keyed Function records are pinned (`RECORD_FLAG_EXTERNAL_CARRIER`): a +/// closure is not a shape carrier the collector notes, so its record must not +/// be pruned while the closure lives. They are canonical per facts, so the +/// set is bounded by the program's distinct function key lists. +pub(crate) fn refresh_closure_shape(ptr: usize) { + unsafe { + let closure = ptr as *mut ClosureHeader; + let dict = function_dictionary_shape(); + if (*closure).shape_id == dict { + return; + } + if super::props::has_state(ptr) { + closure_become_dictionary(closure); + return; + } + let base = birth_shape_for_body((*closure).func_ptr); + let bag = super::props::bag_of(ptr); + let next = if bag.is_null() { + base + } else { + match shapes::object_shape_descriptor(bag) { + Some(d) + if d.object_kind == ShapeObjectKind::Ordinary + && d.hole_count == 0 + && d.semantic_generation == 0 => + { + if d.logical_key_count == 0 { + base + } else { + let proto_id = + shapes::shape_proto_id(base).unwrap_or(INTRINSIC_SERIAL_FUNCTION); + let id = shapes::publish_shape_result( + shapes::shape_descriptor_ensure_with_generation( + d.keys as usize as *const crate::array::ArrayHeader, + d.logical_key_count, + d.live_inline_slot_count, + 0, + ShapeObjectKind::Function, + proto_id, + function_shape_summary(ShapeObjectKind::Function), + ), + ); + shapes::note_external_shape_carrier(shapes::shape_descriptor_by_id(id)); + id + } + } + _ => dict, + } + }; + // GC_STORE_AUDIT(POINTER_FREE): a ShapeId, never a heap reference. + (*closure).shape_id = next; + } } -/// [`is_intrinsic_function_key`] over raw key bytes. -#[inline] -pub(crate) fn is_intrinsic_function_key_bytes(key: &[u8]) -> bool { - matches!(key, b"name" | b"length" | b"prototype") +/// Does the Function ShapeId `id` describe a receiver that inherits `key` +/// from `Function.prototype`? True for a base or keyed (never dictionary) +/// Function shape whose prototype identity is Function.prototype's and whose +/// own key list does not contain `key`. +pub(crate) fn function_shape_inherits_from_function_prototype(id: u32, key: &[u8]) -> bool { + if id == function_dictionary_shape() { + return false; + } + let Some(record) = shapes::shape_record_by_id(id) else { + return false; + }; + let Some(descriptor) = shapes::shape_descriptor_by_id(id) else { + return false; + }; + if descriptor.object_kind != ShapeObjectKind::Function + || descriptor.proto_id != INTRINSIC_SERIAL_FUNCTION + { + return false; + } + let keys = record.keys(); + if keys == 0 || descriptor.logical_key_count == 0 { + return true; + } + // SAFETY: a live slab record's keys array. + unsafe { + crate::object::keys_find_slot_by_bytes_resolved( + keys as usize as *const crate::array::ArrayHeader, + descriptor.logical_key_count, + key, + ) + .is_none() + } } /// Raw kind probe for a pointer the caller has already range/band-checked @@ -332,17 +418,42 @@ mod tests { assert_ne!(id, unsafe { (*fresh(plain_body)).shape_id }); } + /// An own string key moves a function to a KEYED Function shape: the + /// same key list gives the same ShapeId (canonical per facts), the value + /// lives in the bag, and the shape stays described (not dictionary). #[test] - fn intrinsic_keys_keep_the_base_shape_and_anything_else_leaves_it() { + fn own_keys_give_a_canonical_keyed_function_shape() { let _lock = crate::gc::global_side_table_test_lock(); let _t = crate::gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers(); - let c = fresh(plain_body); - closure_set_dynamic_prop(c as usize, "prototype", 1.0); - closure_set_dynamic_prop(c as usize, "name", 1.0); - assert_eq!(kind_of(c), Some(ShapeObjectKind::Function)); - closure_set_dynamic_prop(c as usize, "tag", 7.0); - assert_eq!(kind_of(c), Some(ShapeObjectKind::FunctionDictionary)); - assert_eq!(unsafe { (*c).shape_id }, function_dictionary_shape()); + let a = fresh(plain_body); + let b = fresh(plain_body); + let base = unsafe { (*a).shape_id }; + for c in [a, b] { + closure_set_dynamic_prop(c as usize, "tag", 7.0); + closure_set_dynamic_prop(c as usize, "kind", 8.0); + } + let ka = unsafe { (*a).shape_id }; + assert_ne!(ka, base, "an own key leaves the base shape"); + assert_eq!(ka, unsafe { (*b).shape_id }, "same keys, same ShapeId"); + assert_eq!(kind_of(a), Some(ShapeObjectKind::Function)); + assert!(shapes::is_exotic_shape_id(ka) && !shapes::is_site_matchable_shape_id(ka)); + assert_eq!(shapes::shape_proto_id(ka), Some(INTRINSIC_SERIAL_FUNCTION)); + assert!(unsafe { closure_on_base_shape(a) }); + assert!(!function_shape_inherits_from_function_prototype(ka, b"tag")); + assert!(function_shape_inherits_from_function_prototype(ka, b"bind")); + assert_eq!( + crate::closure::closure_get_own_dynamic_prop(a as usize, "kind"), + Some(8.0) + ); + // Removing a key tombstones the bag: the function becomes dictionary. + assert!(crate::closure::closure_delete_own_dynamic_prop( + a as usize, "tag" + )); + assert_eq!(unsafe { (*a).shape_id }, function_dictionary_shape()); + assert_eq!( + crate::closure::closure_get_own_dynamic_prop(a as usize, "tag"), + None + ); } #[test] diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index b375282963..a454c38a81 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -669,6 +669,18 @@ pub(crate) unsafe fn layout_init_unknown_fresh(user_ptr: *mut u8) { set_layout_state(header, GC_LAYOUT_UNKNOWN); } +/// A header-level child edge was just installed on `user_ptr` (a closure's +/// own-property bag): a `GC_LAYOUT_POINTER_FREE` payload state must not let +/// any walk treat the cell as edge-free. Other states already visit it. +pub(crate) unsafe fn layout_note_closure_edge_installed(user_ptr: *mut u8) { + let Some(header) = layout_header_for_user(user_ptr as usize) else { + return; + }; + if (*header)._reserved & GC_LAYOUT_STATE_MASK == GC_LAYOUT_POINTER_FREE { + layout_mark_unknown(user_ptr); + } +} + pub(crate) unsafe fn layout_mark_unknown(user_ptr: *mut u8) { let Some(header) = layout_header_for_user(user_ptr as usize) else { return; @@ -1893,7 +1905,12 @@ pub(super) unsafe fn gc_child_slots(header: *mut GcHeader) -> HeapChildSlotItera let Some(range) = crate::closure::gc_capture_slot_range(closure) else { return HeapChildSlotIterator::empty(); }; + // D1: the function's own-property bag is a raw-pointer child + // edge (0 = none), like an `ObjectHeader`'s `meta`: marking keeps + // it alive and evacuation rewrites it. + let props = &mut (*closure).props as *mut _ as *mut u64; HeapChildSlotIterator::new(header, None, range) + .with_meta_slot((*props != 0).then_some(props)) } GcLayoutSlotKind::None => HeapChildSlotIterator::empty(), } diff --git a/crates/perry-runtime/src/gc/layout_slot_visit.rs b/crates/perry-runtime/src/gc/layout_slot_visit.rs index 472ee22777..3bc0157204 100644 --- a/crates/perry-runtime/src/gc/layout_slot_visit.rs +++ b/crates/perry-runtime/src/gc/layout_slot_visit.rs @@ -295,13 +295,8 @@ pub(super) unsafe fn visit_gc_rewrite_slot_descriptors( visit_gc_layout_slot_descriptors(header, &mut visit); } GcRewriteDescriptorKind::Closure => { + // Captures and the own-property bag edge (`ClosureCaptures`). visit_gc_layout_slot_descriptors(header, &mut visit); - crate::closure::visit_closure_dynamic_prop_value_slots_mut(user_ptr as usize, |slot| { - visit(fixed_slot(slot)); - }); - crate::closure::visit_closure_static_prototype_slot_mut(user_ptr as usize, |slot| { - visit(fixed_slot(slot)); - }); } GcRewriteDescriptorKind::Promise => { let promise = user_ptr as *mut crate::promise::Promise; diff --git a/crates/perry-runtime/src/gc/tests/barrier.rs b/crates/perry-runtime/src/gc/tests/barrier.rs index c2f664880f..7fe2348c55 100644 --- a/crates/perry-runtime/src/gc/tests/barrier.rs +++ b/crates/perry-runtime/src/gc/tests/barrier.rs @@ -1473,8 +1473,11 @@ fn test_incremental_barrier_marks_closure_static_prototype_store() { drain_incremental_mark_barrier_seeds(&valid_ptrs); assert_marked_user_ptr(proto, "closure static prototype"); + // The prototype is reached through the closure's own-property bag and its + // state record (closure -> bag -> meta -> state -> proto): every edge on + // the path is checked, none may be missing. let stats = verify_marked_heap_no_unmarked_children(); - assert_eq!(stats.checked_edges, 1); + assert!(stats.checked_edges >= 1); assert_eq!(stats.missing_edges, 0); clear_mark_user_ptr(closure as usize); crate::closure::test_clear_closure_side_tables(); diff --git a/crates/perry-runtime/src/gc/tests/global_sink_isolation.rs b/crates/perry-runtime/src/gc/tests/global_sink_isolation.rs index 23e38ee1d1..654c165d10 100644 --- a/crates/perry-runtime/src/gc/tests/global_sink_isolation.rs +++ b/crates/perry-runtime/src/gc/tests/global_sink_isolation.rs @@ -97,40 +97,6 @@ fn the_probe_catches_a_bare_process_global_sink() { // Converted tables. One test per `test_clear_*` helper the guards call. // --------------------------------------------------------------------------- -/// `closure::test_clear_closure_side_tables` — `CLOSURE_PROPS`, -/// `CLOSURE_STATIC_PROTOTYPES`, `CLOSURE_DELETED_KEYS`. -/// -/// This is the #7671 shape exactly: a value written through the closure -/// dynamic-property table and read back came out `TAG_UNDEFINED` because a GC -/// guard on another libtest thread had emptied the table in between. -#[test] -fn closure_side_tables_survive_a_guard_clear_on_another_thread() { - // A synthetic, per-test key: the tables are keyed by heap address but the - // accessors treat the key as an opaque integer for store/load. - let owner = 0x_C105_0000_7672_usize; - let survived = survives_a_foreign_clear( - "closure dynamic props", - || { - crate::closure::closure_set_dynamic_prop(owner, "probe7672", 42.5); - crate::closure::closure_set_static_prototype(owner, 0x7FFD_0000_0000_7672); - crate::closure::closure_mark_key_deleted(owner, "goneKey"); - }, - move || { - crate::closure::closure_get_own_dynamic_prop(owner, "probe7672") == Some(42.5) - && crate::closure::closure_has_own_dynamic_prop(owner, "probe7672") - && crate::closure::closure_static_prototype(owner) == Some(0x7FFD_0000_0000_7672) - && crate::closure::closure_is_key_deleted(owner, "goneKey") - }, - foreign_guard_clear, - ); - assert!( - survived, - "a closure dynamic property written on this thread was destroyed by the GC \ - test guards' state reset running on another thread (#7672 / #7671). The \ - table's `per_test_global!` declaration is what prevents this." - ); -} - /// `symbol::test_clear_symbol_side_table_roots` — `SYMBOL_PROPERTIES`, /// `SYMBOL_PROPERTY_ATTRS`, `CLASS_STATIC_SYMBOLS`, `SYMBOL_ACCESSOR_PROPERTIES` /// and the `SYMBOL_POINTERS` rebuild. diff --git a/crates/perry-runtime/src/gc/tests/promote_in_place.rs b/crates/perry-runtime/src/gc/tests/promote_in_place.rs index fb0f90ada7..e55bd554bb 100644 --- a/crates/perry-runtime/src/gc/tests/promote_in_place.rs +++ b/crates/perry-runtime/src/gc/tests/promote_in_place.rs @@ -788,56 +788,6 @@ fn a_first_cycle_attempt_that_its_own_trace_refutes_rolls_back_and_evacuates() { ); } -/// A speculative promotion temporarily gives every nursery block old-gen -/// semantics. Young-entry root logs must nevertheless survive an attempt that -/// is rolled back: the evacuation retry still depends on them to find values -/// reachable only through a side table. -#[test] -fn first_cycle_rollback_preserves_young_side_table_roots_for_the_retry() { - let _guard = CopyingNurseryTestGuard::new(0); - let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); - let _promote = InPlacePromotionTestGuard::enabled(1000); - clear_young_survival_for_tests(); - // Exhaust the blind-promotion budget so the speculative attempt performs - // the mutable-root mark/rewrite walks that consume young logs. - seed_untraced_promoted_bytes_for_tests(usize::MAX); - gc_register_mutable_root_scanner(crate::closure::scan_closure_dynamic_props_roots_mut); - - let owner = crate::arena::arena_alloc_gc_old( - std::mem::size_of::(), - std::mem::align_of::(), - GC_TYPE_CLOSURE, - ) as usize; - unsafe { init_test_closure(owner as *mut u8) }; - // Arm the remembered-set reconstruction before publishing the value, then - // remove the ordinary old-object edge so the young log is the only root. - let _ = remembered_dirty_snapshot(); - let value = young_leaf(); - crate::closure::closure_set_dynamic_prop(owner, "memo", f64::from_bits(string_bits(value))); - remembered_set_clear(); - for _ in 0..64 { - let _garbage = young_leaf(); - } - - let attempts_before = first_cycle_promotion_attempts(); - let rollbacks_before = first_cycle_promotion_rollbacks(); - let trace = collect_minor_trace(GcTriggerKind::Direct); - - assert_copied_minor_trace(&trace, true, CopiedMinorFallbackReason::None, false); - assert_eq!(first_cycle_promotion_attempts() - attempts_before, 1); - assert_eq!(first_cycle_promotion_rollbacks() - rollbacks_before, 1); - let bits = crate::closure::closure_get_own_dynamic_prop(owner, "memo") - .expect("the old owner must retain its side-table value") - .to_bits(); - let value_after = (bits & POINTER_MASK) as usize; - assert_eq!(bits & TAG_MASK, STRING_TAG); - assert_ne!( - value_after, value, - "the retry must evacuate a value reachable only through the young log" - ); - assert!(crate::arena::pointer_in_nursery(value_after)); -} - // --------------------------------------------------------------------------- // #7913 interaction: old-page relocation vs a still-DESCRIBED promoted run // --------------------------------------------------------------------------- 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 66a45d5bcb..c3bf9b2f63 100644 --- a/crates/perry-runtime/src/gc/tests/young_log_tests.rs +++ b/crates/perry-runtime/src/gc/tests/young_log_tests.rs @@ -64,9 +64,13 @@ fn walk_opt(table: &'static str) -> Option { } // ---------------------------------------------------------------- closures +// +// Function own properties live in the function's bag (`closure::props`), a +// traced child edge: they follow their owner through a copying minor with no +// young log. These keep the semantic halves of the old table proofs. #[test] -fn young_closure_prop_value_is_moved_through_the_log() { +fn young_closure_prop_value_moves_with_its_owner() { let _guard = CopyingNurseryTestGuard::new(1); gc_register_mutable_root_scanner(crate::closure::scan_closure_dynamic_props_roots_mut); @@ -97,23 +101,10 @@ fn young_closure_prop_value_is_moved_through_the_log() { crate::closure::closure_get_own_dynamic_prop(owner, "memo").is_none(), "the stale owner key must be gone" ); - let row = walk("closure.dynamic_props"); - assert!( - row.partial, - "a copying minor must take the young-scoped walk" - ); - assert!( - row.visited >= 1, - "the logged owner must have been visited: {row:?}" - ); - assert!( - row.kept >= 1, - "a survivor still young must stay logged: {row:?}" - ); } #[test] -fn young_value_under_an_old_closure_owner_is_logged_by_the_value() { +fn young_value_under_an_old_closure_owner_survives_a_minor() { let _guard = CopyingNurseryTestGuard::new(0); gc_register_mutable_root_scanner(crate::closure::scan_closure_dynamic_props_roots_mut); @@ -135,71 +126,10 @@ fn young_value_under_an_old_closure_owner_is_logged_by_the_value() { & POINTER_MASK) as usize; assert_ne!(proto_after, proto); assert!(crate::arena::pointer_in_nursery(proto_after)); - assert!(walk("closure.dynamic_props").partial); } #[test] -fn layout_slot_rewrite_rearms_old_closure_for_a_young_value() { - let _guard = CopyingNurseryTestGuard::new(0); - gc_register_mutable_root_scanner(crate::closure::scan_closure_dynamic_props_roots_mut); - - let owner = old_closure(); - crate::closure::closure_set_dynamic_prop(owner, "memo", 42.0); - let value = young_leaf(); - crate::closure::visit_closure_dynamic_prop_value_slots_mut(owner, |slot| unsafe { - *slot = string_bits(value); - }); - - let _ = gc_collect_minor(); - - let bits = crate::closure::closure_get_own_dynamic_prop(owner, "memo") - .expect("old owner keeps its rewritten entry") - .to_bits(); - let value_after = (bits & POINTER_MASK) as usize; - assert_ne!( - value_after, value, - "the rewritten young value must be evacuated through the re-armed log" - ); - assert!(crate::arena::pointer_in_nursery(value_after)); - let row = walk("closure.dynamic_props"); - assert!(row.partial); - assert!( - row.visited >= 1, - "the re-armed owner must be visited: {row:?}" - ); -} - -#[test] -fn layout_slot_rewrite_rearms_old_closure_for_a_young_prototype() { - let _guard = CopyingNurseryTestGuard::new(0); - gc_register_mutable_root_scanner(crate::closure::scan_closure_dynamic_props_roots_mut); - - let owner = old_closure(); - crate::closure::closure_set_static_prototype(owner, crate::value::TAG_NULL); - let prototype = young_leaf(); - crate::closure::visit_closure_static_prototype_slot_mut(owner, |slot| unsafe { - *slot = string_bits(prototype); - }); - - let _ = gc_collect_minor(); - - let bits = crate::closure::closure_static_prototype(owner).expect("prototype kept"); - let prototype_after = (bits & POINTER_MASK) as usize; - assert_ne!( - prototype_after, prototype, - "the rewritten young prototype must be evacuated through the re-armed log" - ); - assert!(crate::arena::pointer_in_nursery(prototype_after)); - let row = walk("closure.dynamic_props"); - assert!(row.partial); - assert!( - row.visited >= 1, - "the re-armed owner must be visited: {row:?}" - ); -} - -#[test] -fn old_closure_entries_are_skipped_by_a_minor() { +fn old_closure_entries_survive_a_minor() { let _guard = CopyingNurseryTestGuard::new(0); gc_register_mutable_root_scanner(crate::closure::scan_closure_dynamic_props_roots_mut); @@ -214,13 +144,6 @@ fn old_closure_entries_are_skipped_by_a_minor() { Some(42.0) ); assert!(crate::closure::closure_is_key_deleted(owner, "name")); - let row = walk("closure.dynamic_props"); - assert!(row.partial); - assert!(row.table_len >= 1, "{row:?}"); - assert_eq!( - row.visited, 0, - "an old owner with no heap values must not be visited by a minor: {row:?}" - ); } #[test] diff --git a/crates/perry-runtime/src/object/native_call_method/function_shape.rs b/crates/perry-runtime/src/object/native_call_method/function_shape.rs index ed6b425d53..887abc7846 100644 --- a/crates/perry-runtime/src/object/native_call_method/function_shape.rs +++ b/crates/perry-runtime/src/object/native_call_method/function_shape.rs @@ -1,12 +1,13 @@ //! Method calls on a function object, answered from its SHAPE. //! -//! A closure on its base Function shape (`closure::shape`) has exactly the -//! intrinsic own keys (`name`, `length`, `prototype`) and inherits from the -//! prototype its shape names. So for any other key the answer is the -//! prototype's own slot, and the call is decided by that slot's VALUE: +//! A closure on a described Function shape (`closure::shape`: base or keyed, +//! not FunctionDictionary) has exactly the own keys its shape lists and +//! inherits from the prototype its shape names. So for a key the list lacks +//! the answer is the prototype's own slot, and the call is decided by that +//! slot's VALUE: //! -//! 1. one compare: the receiver's +4 word is this agent's base Function -//! ShapeId (ownership then proven by the tracked GC header); +//! 1. the receiver's +4 word is a Function ShapeId naming Function.prototype +//! whose key list lacks the key (ownership proven by the tracked header); //! 2. one lookup: the key's data slot on `Function.prototype`; //! 3. identity by value: the slot still holds the intrinsic `bind` / `call` / //! `apply` closure (its code pointer) — then run exactly what the by-name @@ -49,17 +50,13 @@ pub(crate) unsafe fn try_function_shape_method_call( if !crate::object::shapes::is_exotic_shape_id(word) { return None; } - if word - != crate::closure::shape::function_base_shape( - crate::closure::shape::FunctionProtoKind::Function, - ) - { - return None; - } if !crate::closure::is_closure_ptr(addr) { return None; } - if crate::closure::shape::is_intrinsic_function_key_bytes(name) { + // A DESCRIBED Function shape (base or keyed) whose prototype is + // Function.prototype, and whose own key list does not hold `name` — + // then the receiver has no own `name` and inherits it from the prototype. + if !crate::closure::shape::function_shape_inherits_from_function_prototype(word, name) { return None; } // (2) The prototype the shape names, and its own data slot for the key. diff --git a/scripts/addr_class_ratchet_baseline.txt b/scripts/addr_class_ratchet_baseline.txt index 54c9356081..b75f97452c 100644 --- a/scripts/addr_class_ratchet_baseline.txt +++ b/scripts/addr_class_ratchet_baseline.txt @@ -216,7 +216,6 @@ handle-floor | crates/perry-stdlib/src/webcrypto/supports.rs | 1 handle-floor | crates/perry-stdlib/src/webcrypto/util.rs | 5 handle-floor | crates/perry-stdlib/src/zlib.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/buffer/access.rs | 1 -lone-valid-obj-ptr | crates/perry-runtime/src/closure/dynamic_props.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/collection_iter.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/error.rs | 3 lone-valid-obj-ptr | crates/perry-runtime/src/intl.rs | 1 diff --git a/scripts/gc_rekeyed_key_tables.json b/scripts/gc_rekeyed_key_tables.json index 82d03630df..c233eeb88d 100644 --- a/scripts/gc_rekeyed_key_tables.json +++ b/scripts/gc_rekeyed_key_tables.json @@ -32,10 +32,10 @@ "why": "#8191: registered prune retains on !is_dead_owner over the wrapper ObjectHeader address (builtins/formatting/boxed_primitives.rs)." }, { - "site": "crates/perry-runtime/src/closure/dynamic_props.rs::scan_closure_owner", - "table": "CLOSURE_PROPS / CLOSURE_STATIC_PROTOTYPES / CLOSURE_DELETED_KEYS", + "site": "crates/perry-runtime/src/closure/dynamic_props.rs::scan_closure_dynamic_props_roots_mut", + "table": "WASM_FUNCREF_EXTERNALS", "death": "dead_owner:prune_dead_closure_side_table_owners", - "why": "All three tables are retained on the GC_TYPE_CLOSURE-narrowed predicate in one prune (closure/dynamic_props.rs:206-214). #9754: the per-owner body shared by the full walk and the young-log walk." + "why": "The wasm-host funcref table (the only closure-address-keyed table left: function own properties, deleted markers and recorded prototypes live in the closure's traced own-property bag). Retained on the GC_TYPE_CLOSURE-narrowed predicate in prune_dead_closure_side_table_owners." }, { "site": "crates/perry-runtime/src/fs/mod.rs::scan_filehandle_object_fd_metadata_roots_mut", diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index a7ee90c8da..b1b84027b4 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -274,12 +274,6 @@ "verdict": "not_a_gc_pointer", "why": "Cache miss counter (telemetry). Holds no address." }, - { - "file": "crates/perry-runtime/src/closure/dynamic_props.rs", - "name": "TEST_SUPPRESS_CLOSURE_YOUNG_NOTE", - "verdict": "not_a_gc_pointer", - "why": "#9976: a `Cell` test seam that suppresses the closure young-log note so a test can exercise the full-walk fallback. A FLAG; there is no slot for the collector." - }, { "file": "crates/perry-runtime/src/closure/registry.rs", "name": "BODY_RECORD_LOOKUPS", From 0acc1e410af64142ca3bdc8e1d05cc7ce2e72fdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 26 Sep 2026 10:31:49 +0000 Subject: [PATCH 02/12] fix(runtime): bind reads length through a getter, a deleted name/length is inherited, a recorded prototype supplies call/apply/bind Three function-object mismatches with node, each fixed where the D1 design makes it natural: - Function.prototype.bind runs Get(target, "length"): a length ACCESSOR on the target (only a FunctionDictionary target can carry one) is invoked. - A deleted own name/length is no longer own, so the read continues on Function.prototype (own name "" and length 0) instead of answering undefined. - call/apply/bind on a FunctionDictionary receiver resolve against its ACTUAL prototype: a recorded prototype\x27s method wins (called with the function as this); the intrinsic runs the tower\x27s semantics. Node-comparison test: test-files/test_gap_function_own_state.ts. --- .../src/closure/dispatch/bound.rs | 28 +++++++- .../src/closure/dynamic_props.rs | 14 ++++ .../native_call_method/function_shape.rs | 61 +++++++++++++++++ test-files/test_gap_function_own_state.ts | 67 +++++++++++++++++++ 4 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 test-files/test_gap_function_own_state.ts diff --git a/crates/perry-runtime/src/closure/dispatch/bound.rs b/crates/perry-runtime/src/closure/dispatch/bound.rs index a3244d5ee9..46f33ad5ec 100644 --- a/crates/perry-runtime/src/closure/dispatch/bound.rs +++ b/crates/perry-runtime/src/closure/dispatch/bound.rs @@ -705,7 +705,33 @@ pub unsafe extern "C" fn js_function_bind( let target_closure = target_is_closure.then(|| { JSValue::from_bits(target_h.get_nanbox_f64().to_bits()).as_pointer::() }); - let target_len_f = if let Some(target_closure) = target_closure { + // Spec step 5: `HasOwnProperty(Target, "length")` then `Get(Target, + // "length")` — a GETTER installed by `Object.defineProperty(fn, "length", + // {get})` runs. Only a FunctionDictionary target can carry one (every + // accessor install leaves the described shapes), so the common case stays + // the own-data / registered-length read below. The getter may allocate: + // everything live is rooted above, and the target is re-read after. + let accessor_len = target_closure.and_then(|t| unsafe { + if crate::closure::shape::closure_on_base_shape(t) { + return None; + } + crate::object::get_accessor_descriptor(t as usize, "length")?; + let v = crate::closure::closure_get_dynamic_prop(t as usize, "length"); + let jv = JSValue::from_bits(v.to_bits()); + Some(if jv.is_int32() { + jv.as_int32() as f64 + } else if jv.is_number() { + jv.as_number() + } else { + 0.0 + }) + }); + let target_closure = target_is_closure.then(|| { + JSValue::from_bits(target_h.get_nanbox_f64().to_bits()).as_pointer::() + }); + let target_len_f = if let Some(len) = accessor_len { + len + } else if let Some(target_closure) = target_closure { match crate::closure::closure_get_own_dynamic_prop(target_closure as usize, "length") { Some(v) => { let jv = JSValue::from_bits(v.to_bits()); diff --git a/crates/perry-runtime/src/closure/dynamic_props.rs b/crates/perry-runtime/src/closure/dynamic_props.rs index 174c500cf0..af3f8d8100 100644 --- a/crates/perry-runtime/src/closure/dynamic_props.rs +++ b/crates/perry-runtime/src/closure/dynamic_props.rs @@ -490,6 +490,20 @@ pub fn closure_get_dynamic_prop(ptr: usize, prop: &str) -> f64 { } break; } + // #3655 + spec: a DELETED own `name`/`length` is not an own property any + // more, so the read continues on the prototype — `Function.prototype` + // itself has own `name` ("") and `length` (0). + if matches!(prop, "name" | "length") && !on_base && closure_is_key_deleted(ptr, prop) { + let proto = super::shape::FUNCTION_PROTOTYPE_PTR.load(std::sync::atomic::Ordering::Acquire); + if proto != 0 && proto as usize != ptr { + let key_hdr = crate::string::js_string_from_bytes(prop.as_ptr(), prop.len() as u32); + let v = crate::object::js_object_get_field_by_name( + proto as *const crate::object::ObjectHeader, + key_hdr as *const crate::StringHeader, + ); + return f64::from_bits(v.bits()); + } + } // Every function's [[Prototype]] is %Function.prototype% — an expando // installed there (`Function.prototype.property = 12`), or a property // installed via `Object.defineProperty(Function.prototype, k, {...})`, diff --git a/crates/perry-runtime/src/object/native_call_method/function_shape.rs b/crates/perry-runtime/src/object/native_call_method/function_shape.rs index 887abc7846..6be6c0851f 100644 --- a/crates/perry-runtime/src/object/native_call_method/function_shape.rs +++ b/crates/perry-runtime/src/object/native_call_method/function_shape.rs @@ -56,6 +56,9 @@ pub(crate) unsafe fn try_function_shape_method_call( // A DESCRIBED Function shape (base or keyed) whose prototype is // Function.prototype, and whose own key list does not hold `name` — // then the receiver has no own `name` and inherits it from the prototype. + if word == crate::closure::shape::function_dictionary_shape() { + return dictionary_function_proto_method_call(object, addr, name, args_ptr, args_len); + } if !crate::closure::shape::function_shape_inherits_from_function_prototype(word, name) { return None; } @@ -79,6 +82,64 @@ pub(crate) unsafe fn try_function_shape_method_call( super::common_methods::dispatch_function_proto_method(object, which, args_ptr, args_len) } +/// `bind`/`call`/`apply` on a FunctionDictionary receiver — one whose +/// `[[Prototype]]` may be RECORDED (`Object.setPrototypeOf(fn, p)`). The +/// shape answers nothing, so the key is resolved the ordinary way: an own +/// property declines to the full path; otherwise the inherited value comes +/// from the receiver's ACTUAL prototype (`reify_function_method_value`, which +/// reads `getPrototypeOf(fn)`). The intrinsic runs the tower's semantics; any +/// other callable (`p.call`) is invoked with the function as `this`. +unsafe fn dictionary_function_proto_method_call( + object: f64, + addr: usize, + name: &[u8], + args_ptr: *const f64, + args_len: usize, +) -> Option { + let method: &'static [u8] = match name { + b"call" => b"call", + b"apply" => b"apply", + b"bind" => b"bind", + _ => return None, + }; + let key = std::str::from_utf8(method).ok()?; + if crate::closure::closure_has_own_dynamic_prop(addr, key) + || crate::object::get_accessor_descriptor(addr, key).is_some() + { + return None; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver_h = scope.root_nanbox_f64(object); + let value = crate::closure::reify_function_method_value(object, method); + let value_h = scope.root_nanbox_f64(value); + let jv = JSValue::from_bits(value.to_bits()); + if !jv.is_pointer() { + return None; + } + let func = crate::closure::get_valid_func_ptr(jv.as_pointer::()); + if let Some(which) = crate::object::global_this::function_prototype_intrinsic_of(func) { + return super::common_methods::dispatch_function_proto_method( + receiver_h.get_nanbox_f64(), + which, + args_ptr, + args_len, + ); + } + if func.is_null() { + return None; + } + // A user callable inherited from the recorded prototype: an ordinary + // method call with the function as `this`. + let prev_this_h = scope.root_nanbox_u64( + super::IMPLICIT_THIS.with(|c| c.replace(receiver_h.get_nanbox_f64().to_bits())), + ); + let callee = + crate::closure::rebind_explicit_this(value_h.get_nanbox_f64(), receiver_h.get_nanbox_f64()); + let result = crate::closure::js_native_call_value(callee, args_ptr, args_len); + super::IMPLICIT_THIS.with(|c| c.set(prev_this_h.get_nanbox_u64())); + Some(result) +} + #[cfg(test)] thread_local! { /// Calls this path answered (tests prove the path FIRES, not just that diff --git a/test-files/test_gap_function_own_state.ts b/test-files/test_gap_function_own_state.ts new file mode 100644 index 0000000000..07c0dcdd64 --- /dev/null +++ b/test-files/test_gap_function_own_state.ts @@ -0,0 +1,67 @@ +// Function objects keep their own properties, deleted intrinsics and a +// recorded [[Prototype]] in the function object itself. Each case below +// differed from node before the every-receiver-shape closures stage. + +// 1. bind() reads `length` with Get(): an accessor installed on the target runs. +function withAccessor(a: number, b: number) { + return a + b; +} +Object.defineProperty(withAccessor, "length", { get: () => 42 }); +console.log("accessor length", withAccessor.length, withAccessor.bind(null).length, withAccessor.bind(null, 1).length); + +// 2. A deleted own `name` / `length` is inherited from Function.prototype. +function withDeletes(a: number, b: number) { + return a + b; +} +delete (withDeletes as any).name; +delete (withDeletes as any).length; +console.log( + "deleted", + JSON.stringify((withDeletes as any).name), + (withDeletes as any).length, + Object.prototype.hasOwnProperty.call(withDeletes, "name"), + withDeletes.call(null, 1, 2), +); + +// 3. A recorded [[Prototype]]'s call/apply/bind win over Function.prototype's. +function withProto() { + return 3; +} +const proto = { + call(this: any, x: number) { + return "proto call " + (this === withProto) + " " + x; + }, + apply() { + return "proto apply"; + }, + bind() { + return "proto bind"; + }, +}; +Object.setPrototypeOf(withProto, proto); +console.log("recorded proto", (withProto as any).call(null, 7), (withProto as any).apply(), (withProto as any).bind()); + +// A recorded prototype that is itself a function: its Function.prototype +// methods still apply to the receiver. +function base(this: any, a: number) { + return "base " + a; +} +function derived(this: any, a: number) { + return "derived " + a; +} +Object.setPrototypeOf(derived, base); +console.log("function proto", derived.call(null, 1), derived.apply(null, [2]), derived.bind(null, 3)()); + +// Own properties on functions: values, re-adds after delete, many keys, order. +function bag() {} +(bag as any).b = 2; +(bag as any).a = 1; +(bag as any)[10] = "ten"; +(bag as any)[2] = "two"; +delete (bag as any).b; +(bag as any).b = 22; +console.log("own keys", Object.keys(bag).join(","), (bag as any).a, (bag as any).b, (bag as any)[2]); +for (let i = 0; i < 40; i++) (bag as any)["k" + i] = i; +let s = 0; +for (let i = 0; i < 40; i++) s += (bag as any)["k" + i]; +console.log("many keys", s, Object.keys(bag).length); From eebc89e3e1f1ffd99f3e38fdd4087527a10d8b01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 26 Sep 2026 11:15:23 +0000 Subject: [PATCH 03/12] chore: gate findings for D1 (sink self-test names the remaining per-test closure table, file size, unused unsafe) --- crates/perry-runtime/src/gc/tests/barrier.rs | 4 +--- scripts/global_sink_isolation.py | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/perry-runtime/src/gc/tests/barrier.rs b/crates/perry-runtime/src/gc/tests/barrier.rs index 7fe2348c55..067a88bdf8 100644 --- a/crates/perry-runtime/src/gc/tests/barrier.rs +++ b/crates/perry-runtime/src/gc/tests/barrier.rs @@ -1473,9 +1473,7 @@ fn test_incremental_barrier_marks_closure_static_prototype_store() { drain_incremental_mark_barrier_seeds(&valid_ptrs); assert_marked_user_ptr(proto, "closure static prototype"); - // The prototype is reached through the closure's own-property bag and its - // state record (closure -> bag -> meta -> state -> proto): every edge on - // the path is checked, none may be missing. + // Reached via closure -> bag -> meta -> state record -> proto: no edge missing. let stats = verify_marked_heap_no_unmarked_children(); assert!(stats.checked_edges >= 1); assert_eq!(stats.missing_edges, 0); diff --git a/scripts/global_sink_isolation.py b/scripts/global_sink_isolation.py index 71e9182608..de99618bc0 100644 --- a/scripts/global_sink_isolation.py +++ b/scripts/global_sink_isolation.py @@ -595,9 +595,9 @@ def self_test() -> int: if "test_clear_closure_side_tables" not in helpers: failures.append("the real clear list is missing a helper it certainly calls: %r" % (helpers,)) sources = rust_sources() - kind, _ = declaration_kind(sources, "CLOSURE_PROPS") + kind, _ = declaration_kind(sources, "WASM_FUNCREF_EXTERNALS") if kind != "per_test": - failures.append("CLOSURE_PROPS classified as %r on the real tree" % (kind,)) + failures.append("WASM_FUNCREF_EXTERNALS classified as %r on the real tree" % (kind,)) kind, _ = declaration_kind(sources, "ARGUMENTS_KEYS") if kind != "thread_local": failures.append("ARGUMENTS_KEYS classified as %r on the real tree" % (kind,)) From 1a113cb0a1dfae6e9fe2ad2237fa7dc5be3ec7b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 26 Sep 2026 11:19:12 +0000 Subject: [PATCH 04/12] perf(runtime): the base Function shape answers the method-arm verdict with one compare --- crates/perry-runtime/src/closure/shape.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/perry-runtime/src/closure/shape.rs b/crates/perry-runtime/src/closure/shape.rs index 21c2713b9d..114c3d6f90 100644 --- a/crates/perry-runtime/src/closure/shape.rs +++ b/crates/perry-runtime/src/closure/shape.rs @@ -303,6 +303,10 @@ pub(crate) fn refresh_closure_shape(ptr: usize) { /// Function shape whose prototype identity is Function.prototype's and whose /// own key list does not contain `key`. pub(crate) fn function_shape_inherits_from_function_prototype(id: u32, key: &[u8]) -> bool { + // The common receiver: no own keys, Function.prototype — one compare. + if id == function_base_shape(FunctionProtoKind::Function) { + return true; + } if id == function_dictionary_shape() { return false; } From 252424d34b1022177757a33903ab89d6dc080587 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 26 Sep 2026 11:50:26 +0000 Subject: [PATCH 05/12] perf(runtime): one shape lookup per bag read; keyed Function shapes cache their bind/call/apply verdict Zod binds methods whose bag holds their name (a keyed Function shape), so each bind paid a descriptor lift and key scan for the method-arm verdict and two lookups per bag read (object_keys, then object_live_slot_count). A bag read now takes keys, count and inline bound from one descriptor, and a keyed ShapeId's verdict for the three Function.prototype intrinsics is cached per agent (ids are never reused). --- crates/perry-runtime/src/closure/props.rs | 17 +++++++ crates/perry-runtime/src/closure/shape.rs | 61 +++++++++++++++++++++-- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/crates/perry-runtime/src/closure/props.rs b/crates/perry-runtime/src/closure/props.rs index 37c3257344..8d2d7e1478 100644 --- a/crates/perry-runtime/src/closure/props.rs +++ b/crates/perry-runtime/src/closure/props.rs @@ -56,6 +56,23 @@ unsafe fn bag_ensure(ptr: usize) -> *mut ObjectHeader { /// Own data lookup in an ordinary (or dictionary-mode) bag by key bytes. unsafe fn object_own_get(obj: *const ObjectHeader, key: &[u8]) -> Option { + // One shape lookup for an ordinary bag: keys, count and inline bound all + // come from the same descriptor. + if let Some(d) = crate::object::shapes::object_shape_descriptor(obj) { + if d.object_kind == crate::object::shapes::ShapeObjectKind::Ordinary && d.keys != 0 { + let slot = crate::object::keys_find_slot_by_bytes_resolved( + d.keys as usize as *const crate::array::ArrayHeader, + d.logical_key_count, + key, + )?; + let value = + crate::object::object_field_at_with_live(obj, slot, d.live_inline_slot_count); + if value.bits() == crate::value::TAG_HOLE { + return None; + } + return Some(f64::from_bits(value.bits())); + } + } let keys = crate::object::object_keys(obj); let arr = keys.arr(); if arr.is_null() { diff --git a/crates/perry-runtime/src/closure/shape.rs b/crates/perry-runtime/src/closure/shape.rs index 114c3d6f90..6413f116b9 100644 --- a/crates/perry-runtime/src/closure/shape.rs +++ b/crates/perry-runtime/src/closure/shape.rs @@ -310,9 +310,61 @@ pub(crate) fn function_shape_inherits_from_function_prototype(id: u32, key: &[u8 if id == function_dictionary_shape() { return false; } - let Some(record) = shapes::shape_record_by_id(id) else { - return false; + // A keyed shape: its verdict for the three Function.prototype intrinsics + // is a fact of the (immutable) ShapeId, cached per agent. + let bit = match key { + b"bind" => VERDICT_BIND, + b"call" => VERDICT_CALL, + b"apply" => VERDICT_APPLY, + _ => return keyed_shape_lacks_key(id, key), }; + let slot = (id as usize).wrapping_mul(0x9E37_79B9) >> 26 & (VERDICT_CACHE_LEN - 1); + let cached = VERDICT_CACHE.with(|c| c.get()[slot]); + let mask = if cached.0 == id { + cached.1 + } else { + let mask = VERDICT_KNOWN + | if keyed_shape_lacks_key(id, b"bind") { + VERDICT_BIND + } else { + 0 + } + | if keyed_shape_lacks_key(id, b"call") { + VERDICT_CALL + } else { + 0 + } + | if keyed_shape_lacks_key(id, b"apply") { + VERDICT_APPLY + } else { + 0 + }; + VERDICT_CACHE.with(|c| { + let mut all = c.get(); + all[slot] = (id, mask); + c.set(all); + }); + mask + }; + mask & bit != 0 +} + +const VERDICT_KNOWN: u8 = 1; +const VERDICT_BIND: u8 = 2; +const VERDICT_CALL: u8 = 4; +const VERDICT_APPLY: u8 = 8; +const VERDICT_CACHE_LEN: usize = 64; + +crate::perry_thread_local! { + /// Per-agent cache of keyed Function ShapeIds' verdicts for the + /// Function.prototype intrinsics (ShapeIds are never reused, so an entry + /// can only go unused, never wrong). + static VERDICT_CACHE: std::cell::Cell<[(u32, u8); VERDICT_CACHE_LEN]> = + const { std::cell::Cell::new([(0, 0); VERDICT_CACHE_LEN]) }; +} + +/// A keyed Function shape naming Function.prototype whose key list lacks `key`. +fn keyed_shape_lacks_key(id: u32, key: &[u8]) -> bool { let Some(descriptor) = shapes::shape_descriptor_by_id(id) else { return false; }; @@ -321,14 +373,13 @@ pub(crate) fn function_shape_inherits_from_function_prototype(id: u32, key: &[u8 { return false; } - let keys = record.keys(); - if keys == 0 || descriptor.logical_key_count == 0 { + if descriptor.keys == 0 || descriptor.logical_key_count == 0 { return true; } // SAFETY: a live slab record's keys array. unsafe { crate::object::keys_find_slot_by_bytes_resolved( - keys as usize as *const crate::array::ArrayHeader, + descriptor.keys as usize as *const crate::array::ArrayHeader, descriptor.logical_key_count, key, ) From d175f85247bb62e9756e6e7a7ddf9893680b6257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 00:54:17 +0000 Subject: [PATCH 06/12] fix(runtime): calling a method a function object does not have throws a TypeError `f.m()` on a function object whose own properties and prototype chain have no `m` (never set, deleted, or absent from a replaced prototype) returned the null-object stub, so `delete f.m; f.m()` evaluated to `{}` and the program carried on. Node throws a TypeError. The closure arm of the generic method dispatcher now throws "is not a function" when nothing answers the name; the Function.prototype and Object.prototype builtins are dispatched before that arm, so they are unaffected. test_gap_function_deleted_method.ts compares with node: own methods before and after delete (including a site primed before the delete), several keys with the middle one deleted, deleted own call/bind/apply falling back to Function.prototype's, a bound function's own method, a never-set name, a method on a replaced prototype and a name it lacks, re-adding, and a computed-key call. On main and on the unfixed branch eight lines differ from node. --- .../src/object/native_call_method.rs | 13 ++- .../test_gap_function_deleted_method.ts | 87 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 test-files/test_gap_function_deleted_method.ts diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 8d347ed15d..055c52795a 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -2181,7 +2181,18 @@ pub unsafe extern "C-unwind" fn js_native_call_method( IMPLICIT_THIS.with(|c| c.set(prev_this_h.get_nanbox_u64())); return result; } - return crate::object::null_stub_value(); + // Nothing on the function's own properties or its prototype chain + // answers `method_name` (the Function.prototype and Object.prototype + // builtins were dispatched above; a deleted key reads as absent), so + // the member call has no callee: a TypeError, as in node. This used + // to return the null-object stub, so `delete f.m; f.m()` produced + // `{}` and the program carried on. + crate::error::js_throw_type_error_not_a_function( + std::ptr::null(), + 0, + method_name.as_ptr(), + method_name.len(), + ); } if let Some(r) = crate::builtins::try_console_instance_method_dispatch( diff --git a/test-files/test_gap_function_deleted_method.ts b/test-files/test_gap_function_deleted_method.ts new file mode 100644 index 0000000000..667802c49a --- /dev/null +++ b/test-files/test_gap_function_deleted_method.ts @@ -0,0 +1,87 @@ +// A method deleted from a function object is gone: calling it throws a +// TypeError, and an own method that shadowed a Function.prototype method +// falls back to the inherited one once deleted. + +function describe(label: string, run: () => unknown): void { + try { + const r = run(); + console.log(label, "returned", typeof r, JSON.stringify(r)); + } catch (e) { + console.log(label, "threw", e instanceof TypeError ? "TypeError" : String(e)); + } +} + +function plain(): number { + return 1; +} +(plain as any).m = function (): number { + return 2; +}; +describe("own method before delete", () => (plain as any).m()); +delete (plain as any).m; +describe("own method after delete", () => (plain as any).m()); +console.log("has m after delete", "m" in plain, Object.prototype.hasOwnProperty.call(plain, "m")); + +// Deleted after a read primed the call site. +const arrow = (x: number): number => x + 1; +(arrow as any).twice = (x: number): number => x * 2; +for (let i = 0; i < 3; i++) describe("primed call " + i, () => (arrow as any).twice(i)); +delete (arrow as any).twice; +describe("primed call after delete", () => (arrow as any).twice(5)); + +// Several own keys; delete the middle one. +function multi(): void {} +(multi as any).a = () => "a"; +(multi as any).b = () => "b"; +(multi as any).c = () => "c"; +delete (multi as any).b; +describe("multi a", () => (multi as any).a()); +describe("multi b", () => (multi as any).b()); +describe("multi c", () => (multi as any).c()); + +// An own `call` shadows Function.prototype.call; deleting it restores it. +function add(this: unknown, a: number, b: number): number { + return a + b; +} +(add as any).call = () => "own call"; +describe("own call", () => (add as any).call(null, 1, 2)); +delete (add as any).call; +describe("inherited call after delete", () => add.call(null, 1, 2)); + +// Same for bind and apply. +(add as any).bind = () => "own bind"; +(add as any).apply = () => "own apply"; +delete (add as any).bind; +delete (add as any).apply; +describe("inherited bind after delete", () => add.bind(null, 3)(4)); +describe("inherited apply after delete", () => add.apply(null, [5, 6])); + +// A bound function's own method. +const bound = add.bind(null, 1); +(bound as any).q = () => "q"; +delete (bound as any).q; +describe("bound own after delete", () => (bound as any).q()); + +// A method that was never there. +describe("never set", () => (plain as any).nope()); + +// A function whose prototype was replaced: its methods resolve there, and a +// name it lacks still throws. +function withProto(): void {} +Object.setPrototypeOf(withProto, { + pm(this: unknown): string { + return this === withProto ? "proto method, this ok" : "proto method, wrong this"; + }, +}); +describe("proto method", () => (withProto as any).pm()); +describe("proto missing", () => (withProto as any).nope()); + +// Deleted, then re-added. +(plain as any).m = () => "again"; +describe("re-added", () => (plain as any).m()); + +// A computed-key call of a deleted method. +const key = "dyn"; +(plain as any)[key] = () => "dyn"; +delete (plain as any)[key]; +describe("computed after delete", () => (plain as any)[key]()); From 9a11e43223f8e56165068c75741d3cc6d2a9b35c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 08:17:39 +0000 Subject: [PATCH 07/12] perf: a method site serves a function object's own method from its property object (entry kind bit 61) `F.m()` on a function object always took the dispatcher behind the method site's miss: its own properties had no shaped record. They now live in the function's own-property object (ClosureHeader::props), and a KEYED Function ShapeId is canonical per that object's key list, so "m is inline slot s of the property object" is a fact of the receiver word (capture count | ShapeId), like an ordinary object's own slot. The miss primes a function-bag entry (slot bit 61, METHOD_SITE_FUNCTION_BAG in perry-abi) for a receiver on a keyed Function shape whose property object holds `m` in an inline slot as a plain closure. FunctionDictionary receivers (a delete, an accessor, a symbol key, a recorded prototype) and base-shaped ones (no own keys) are refused. The emitted hit adds one arm to msite.other: load the property object from recv+16, load its slot, then the same checks as an own entry (a live function object, the memoized code pointer) and the same direct call; a reassigned method (same shape, new body) misses on the code pointer. The site's closure test now reads the GcHeader kind (type GC_TYPE_CLOSURE, not forwarded) in front of the value and its code pointer at +8, the layout of the every-receiver-has-a-shape stage; CLOSURE_MAGIC and the type-tag offset are gone from perry-abi. crates/perry/tests/method_site.rs: function_object_receivers_keep_the_dispatcher becomes function_object_receivers_are_served_from_their_property_object, with the same program and node's output, asserting that function-bag entries are primed and the hot calls are served inline; PERRY_METHOD_SITE_STATS reports primes_function. Sabotage (esr_sabotage3.sh): reading the receiver's slot instead of the property object's, never priming, ignoring the kind bit, or dropping the code-pointer compare each turn the test red. --- crates/perry-abi/src/lib.rs | 7 +- crates/perry-codegen/src/expr/method_site.rs | 54 ++++++++- .../perry-runtime/src/object/method_site.rs | 109 ++++++++++++++++-- crates/perry/tests/method_site.rs | 51 +++++--- 4 files changed, 189 insertions(+), 32 deletions(-) diff --git a/crates/perry-abi/src/lib.rs b/crates/perry-abi/src/lib.rs index 30dbb13c0c..0cb02c6c2a 100644 --- a/crates/perry-abi/src/lib.rs +++ b/crates/perry-abi/src/lib.rs @@ -65,8 +65,11 @@ pub const fn method_site_padded_argc(argc: usize) -> usize { } /// The entry `slot` bit for an own key in the receiver's spill buffer. pub const METHOD_SITE_SPILL: u64 = 1 << 62; -/// The index bits of an entry's `slot` word (bits 61 and 60 are reserved for -/// the function-bag and accessor entry kinds). +/// The entry `slot` bit for an own key of a function-object receiver: an +/// inline slot of the object at `ClosureHeader::props`. +pub const METHOD_SITE_FUNCTION_BAG: u64 = 1 << 61; +/// The index bits of an entry's `slot` word (bit 60 is reserved for the +/// accessor entry kind). pub const METHOD_SITE_INDEX_MASK: u64 = (1 << 60) - 1; /// `object::ObjectMeta::spill` (the object-owned overflow buffer). pub const OBJECT_META_SPILL_OFFSET: usize = 32; diff --git a/crates/perry-codegen/src/expr/method_site.rs b/crates/perry-codegen/src/expr/method_site.rs index 824c18df08..7bdf07f658 100644 --- a/crates/perry-codegen/src/expr/method_site.rs +++ b/crates/perry-codegen/src/expr/method_site.rs @@ -15,6 +15,8 @@ //! s < 0 (inherited): PERRY_PROTO_VALIDITY == site.gen else MISS //! h = site.closure ; f = site.func //! own: v = load [recv + HDR + 8*s] ; v is a heap pointer else MISS +//! fn: v = load [[recv + PROPS] + HDR + 8*s] (bit 61: a function's +//! own-property object; same checks as own) //! [v-8] & 0x80FF == CLOSURE ; [v+8] == site.func else NEXT WAY //! h = handle(v) ; f = site.func //! CALL: this = recv ; r = f(h, args...) ; restore this @@ -88,6 +90,7 @@ pub(crate) fn emit_method_site( // live function object: type `GC_TYPE_CLOSURE` and not a forwarded stub // (`closure::is_closure_ptr`'s kind term; there is no payload magic). let func_offset = crate::runtime_abi::CLOSURE_FUNC_PTR_OFFSET as i64; + let props_offset = crate::runtime_abi::CLOSURE_PROPS_OFFSET as i64; let kind_offset = -(crate::runtime_abi::GC_HEADER_SIZE as i64); let kind_mask = (0xFFu16 | (u16::from(crate::runtime_abi::GC_FLAG_FORWARDED) << 8)).to_string(); let closure_kind = crate::runtime_abi::GC_TYPE_CLOSURE.to_string(); @@ -188,12 +191,18 @@ pub(crate) fn emit_method_site( }; let other_idx = ctx.new_block("msite.other"); let other2_idx = ctx.new_block("msite.other2"); + let other3_idx = ctx.new_block("msite.other3"); + let bag_idx = ctx.new_block("msite.fn_bag"); + let bag2_idx = ctx.new_block("msite.fn_bag_load"); let spill_idx = ctx.new_block("msite.spill"); let spill2_idx = ctx.new_block("msite.spill_buf"); let spill3_idx = ctx.new_block("msite.spill_check"); let spill4_idx = ctx.new_block("msite.spill_load"); let other_l = ctx.block_label(other_idx); let other2_l = ctx.block_label(other2_idx); + let other3_l = ctx.block_label(other3_idx); + let bag_l = ctx.block_label(bag_idx); + let bag2_l = ctx.block_label(bag2_idx); let spill_l = ctx.block_label(spill_idx); let spill2_l = ctx.block_label(spill2_idx); let spill3_l = ctx.block_label(spill3_idx); @@ -207,8 +216,8 @@ pub(crate) fn emit_method_site( blk.cond_br(&tagged, &other_l, &own_l); s }; - // other: inherited (bit 63) or own spill (bit 62); any other kind bit is - // not one this site knows, and misses. + // other: inherited (bit 63), own spill (bit 62) or function bag (bit 61); + // any other kind bit is not one this site knows, and misses. ctx.current_block = other_idx; { let blk = ctx.block(); @@ -221,9 +230,39 @@ pub(crate) fn emit_method_site( let spill_bit = blk.lshr(I64, &slot, "62"); let is_spill = blk.icmp_ne(I64, &spill_bit, "0"); let index = blk.and(I64, &slot, &index_mask); - blk.cond_br(&is_spill, &spill_l, &miss_l); + blk.cond_br(&is_spill, &spill_l, &other3_l); index }; + ctx.current_block = other3_idx; + { + let blk = ctx.block(); + let bag_bit = blk.lshr(I64, &slot, "61"); + let is_bag = blk.icmp_ne(I64, &bag_bit, "0"); + blk.cond_br(&is_bag, &bag_l, &miss_l); + } + // function bag: the receiver's own-property object, then its inline slot. + // The keyed Function ShapeId the word matched is canonical per that + // object's key list, so the object exists; the null test is a guard. + ctx.current_block = bag_idx; + let bag = { + let blk = ctx.block(); + let pp = emit_field_ptr(blk, &biased, props_offset); + let bag = blk.load(I64, &pp); + let has = blk.icmp_ne(I64, &bag, "0"); + blk.cond_br(&has, &bag2_l, &miss_l); + bag + }; + ctx.current_block = bag2_idx; + let (bag_v, bag_end) = { + let blk = ctx.block(); + let bptr = blk.inttoptr(I64, &bag); + let base = blk.gep(crate::types::I8, &bptr, &[(I64, &header.to_string())]); + let vp = blk.gep(I64, &base, &[(I64, &index)]); + let v = blk.load(I64, &vp); + let end = blk.label.clone(); + blk.br(&value_l); + (v, end) + }; // own inline: load the slot. ctx.current_block = own_idx; let (inline_v, inline_end) = { @@ -279,7 +318,14 @@ pub(crate) fn emit_method_site( ctx.current_block = value_idx; let own_ub = { let blk = ctx.block(); - let v = blk.phi(I64, &[(&inline_v, &inline_end), (&spill_v, &spill_end)]); + let v = blk.phi( + I64, + &[ + (&inline_v, &inline_end), + (&spill_v, &spill_end), + (&bag_v, &bag_end), + ], + ); let u = blk.sub(I64, &v, &(RECEIVER_BIAS as i64).to_string()); let heap = blk.icmp_ult(I64, &u, &(RECEIVER_SPAN as i64).to_string()); blk.cond_br(&heap, &own_fn_l, &miss_l); diff --git a/crates/perry-runtime/src/object/method_site.rs b/crates/perry-runtime/src/object/method_site.rs index 6647717bc9..4fea0b7244 100644 --- a/crates/perry-runtime/src/object/method_site.rs +++ b/crates/perry-runtime/src/object/method_site.rs @@ -58,10 +58,11 @@ //! address or a name alone; //! * the kind lives in the top bits of [`MethodEntry::slot`]: `0` own inline, //! bit 63 inherited ([`METHOD_SITE_INHERITED`]), bit 62 own spill -//! ([`METHOD_SITE_SPILL`]); bit 61 is RESERVED for the own function-bag -//! kind (function-object receivers, once functions carry a shaped property -//! record) and bit 60 for the accessor kind. A new kind extends the emitted -//! `msite.other` dispatch and [`publish`], nothing else; +//! ([`METHOD_SITE_SPILL`]), bit 61 own function-bag +//! ([`METHOD_SITE_FUNCTION_BAG`]: a function-object receiver whose method is +//! an inline slot of its own-property object); bit 60 is RESERVED for the +//! accessor kind. A new kind extends the emitted `msite.other` dispatch and +//! [`publish`], nothing else; //! * an entry that holds a heap reference stores it in [`MethodEntry::closure`] //! and is registered by [`publish`], so [`scan_method_site_roots_mut`] marks //! and rewrites it; @@ -94,6 +95,11 @@ pub const METHOD_SITE_INHERITED: u64 = 1 << 63; /// The `slot` bit that marks an own entry whose key lives in the receiver's /// spill buffer (`ObjectMeta::spill`) at the index in the low bits. pub const METHOD_SITE_SPILL: u64 = 1 << 62; +/// The `slot` bit that marks an own entry of a FUNCTION receiver: the key is +/// inline slot (low bits) of the function's own-property object +/// (`ClosureHeader::props`, `closure/props.rs`). A keyed Function ShapeId is +/// canonical per that object's key list, so the receiver word pins the slot. +pub const METHOD_SITE_FUNCTION_BAG: u64 = crate::codegen_abi::METHOD_SITE_FUNCTION_BAG; /// The index bits of an entry's `slot` word. pub const METHOD_SITE_INDEX_MASK: u64 = crate::codegen_abi::METHOD_SITE_INDEX_MASK; @@ -144,6 +150,10 @@ const _: () = { std::mem::offset_of!(crate::closure::ClosureHeader, func_ptr) == crate::codegen_abi::CLOSURE_FUNC_PTR_OFFSET ); + assert!( + std::mem::offset_of!(crate::closure::ClosureHeader, props) + == crate::codegen_abi::CLOSURE_PROPS_OFFSET + ); assert!(std::mem::offset_of!(MethodEntry, word) == crate::codegen_abi::METHOD_SITE_WORD_OFFSET); assert!(std::mem::offset_of!(MethodEntry, slot) == crate::codegen_abi::METHOD_SITE_SLOT_OFFSET); assert!(std::mem::offset_of!(MethodEntry, func) == crate::codegen_abi::METHOD_SITE_FUNC_OFFSET); @@ -223,6 +233,12 @@ per_test_global! { static PRIMES_OWN: AtomicU64 = AtomicU64::new(0); static PRIMES_INHERITED: AtomicU64 = AtomicU64::new(0); static MISSES: AtomicU64 = AtomicU64::new(0); + static PRIMES_FUNCTION: AtomicU64 = AtomicU64::new(0); +} + +/// Function-bag entries primed ([`METHOD_SITE_FUNCTION_BAG`]). +pub fn method_site_function_primes() -> u64 { + PRIMES_FUNCTION.load(Ordering::Relaxed) } /// Test/diagnostic counters: (own primes, inherited primes, misses). @@ -234,7 +250,8 @@ pub fn method_site_stats() -> (u64, u64, u64) { ) } -/// `js_method_site_stats(which)`: 0 own primes, 1 inherited primes, 2 misses. +/// `js_method_site_stats(which)`: 0 own primes, 1 inherited primes, 2 misses, +/// 3 function-bag primes. /// Exposed so gap tests can prove a path ran. #[no_mangle] pub extern "C" fn js_method_site_stats(which: i32) -> f64 { @@ -242,6 +259,7 @@ pub extern "C" fn js_method_site_stats(which: i32) -> f64 { (match which { 0 => a, 1 => b, + 3 => method_site_function_primes(), _ => c, }) as f64 } @@ -263,7 +281,8 @@ fn stats_report_enabled() -> bool { } } eprintln!( - "[method-site] primes_own={a} primes_inherited={b} misses={c} marked_value_write_bumps={}{refused}", + "[method-site] primes_own={a} primes_inherited={b} primes_function={} misses={c} marked_value_write_bumps={}{refused}", + method_site_function_primes(), crate::object::proto_validity::marked_value_write_bumps() ); } @@ -335,9 +354,8 @@ pub unsafe extern "C-unwind" fn js_method_site_miss( result_h.get_nanbox_f64() } -/// A cheap first cut of [`ordinary_receiver`]: a heap pointer whose GcHeader -/// says ordinary object. Function-object receivers are not memoized here -/// (their own properties live in a side table, not a shaped record). +/// A cheap first cut of [`ordinary_receiver`] / [`prime_function`]: a heap +/// pointer whose GcHeader says ordinary object or function object. #[inline] fn prime_candidate(recv: f64) -> bool { let bits = recv.to_bits(); @@ -346,8 +364,9 @@ fn prime_candidate(recv: f64) -> bool { } let addr = (bits & crate::value::POINTER_MASK) as usize; crate::value::addr_class::is_above_handle_band(addr) - && unsafe { crate::value::addr_class::try_read_gc_header(addr) } - .is_some_and(|h| h.obj_type == crate::gc::GC_TYPE_OBJECT) + && unsafe { crate::value::addr_class::try_read_gc_header(addr) }.is_some_and(|h| { + h.obj_type == crate::gc::GC_TYPE_OBJECT || h.obj_type == crate::gc::GC_TYPE_CLOSURE + }) } unsafe fn site_of(slot: *mut MethodSiteSlot) -> *mut MethodSite { @@ -450,6 +469,11 @@ unsafe fn prime(slot: *mut MethodSiteSlot, recv: f64, name: &[u8], argc: usize) return; } let addr = (bits & crate::value::POINTER_MASK) as usize; + if crate::value::addr_class::is_above_handle_band(addr) && crate::closure::is_closure_ptr(addr) + { + prime_function(slot, addr, name, argc); + return; + } let Some(obj) = ordinary_receiver(addr) else { let dict = crate::value::addr_class::try_read_gc_header(addr) .is_some_and(|h| h.obj_type == crate::gc::GC_TYPE_OBJECT) @@ -523,6 +547,69 @@ unsafe fn prime(slot: *mut MethodSiteSlot, recv: f64, name: &[u8], argc: usize) prime_inherited(slot, obj, word, name, argc); } +/// Prime a function-bag entry: `recv` is a function object on a KEYED +/// Function shape (its own non-intrinsic properties are exactly the key list +/// of its own-property object, `closure/props.rs`; no accessor, no delete, no +/// recorded prototype — any of those makes it FunctionDictionary, which is +/// refused), and `name` is an inline data slot of that object holding a plain +/// closure. The receiver word (`capture_count | ShapeId`) pins the slot; the +/// emitted hit re-loads the object and the value on every call and compares +/// the value's kind and code pointer, exactly as for an ordinary own entry. +unsafe fn prime_function(slot: *mut MethodSiteSlot, addr: usize, name: &[u8], argc: usize) { + if !address_is_prime_stable(addr) { + refuse(1); + return; + } + let closure = addr as *const crate::closure::ClosureHeader; + let id = (*closure).shape_id; + if id == crate::closure::shape::function_dictionary_shape() + || super::shapes::shape_object_kind_by_id(id) + != Some(super::shapes::ShapeObjectKind::Function) + { + refuse(2); + return; + } + let Some(shape) = super::shapes::shape_descriptor_by_id(id) else { + refuse(1); + return; + }; + let keys = shape.keys as usize as *const crate::array::ArrayHeader; + let bag = crate::closure::props::bag_of(addr); + if keys.is_null() || bag.is_null() { + // A base Function shape: no own non-intrinsic key to serve. + refuse(1); + return; + } + let Some(s) = super::keys_find_slot_by_bytes_resolved(keys, shape.logical_key_count, name) + else { + refuse(1); + return; + }; + if s >= shape.live_inline_slot_count { + refuse(3); + return; + } + let value = field_bits(bag as usize, s); + let Some(func) = direct_callable(value, argc) else { + refuse(5); + return; + }; + if !is_user_method(value, name) { + refuse(13); + return; + } + let entry = MethodEntry { + word: std::ptr::read(addr as *const u64), + slot: s as u64 | METHOD_SITE_FUNCTION_BAG, + func: func as u64, + closure: 0, + gen: 0, + }; + if publish(slot, entry) { + PRIMES_FUNCTION.fetch_add(1, Ordering::Relaxed); + } +} + /// The receiver, if it is an ordinary object a site may learn. unsafe fn ordinary_receiver(addr: usize) -> Option<*const ObjectHeader> { if !crate::value::addr_class::is_above_handle_band(addr) { diff --git a/crates/perry/tests/method_site.rs b/crates/perry/tests/method_site.rs index d38d522606..76c13faf5a 100644 --- a/crates/perry/tests/method_site.rs +++ b/crates/perry/tests/method_site.rs @@ -17,6 +17,17 @@ fn perry_bin() -> PathBuf { /// Compile and run `source`; return (stdout, own primes, inherited primes, misses). fn run(source: &str) -> (String, u64, u64, u64) { + let (stdout, count) = run_counted(source); + ( + stdout, + count("primes_own"), + count("primes_inherited"), + count("misses"), + ) +} + +/// Compile and run `source`; return stdout and a reader of the site counters. +fn run_counted(source: &str) -> (String, impl Fn(&str) -> u64) { let dir = tempfile::tempdir().expect("tempdir"); let entry = dir.path().join("main.ts"); let output = dir.path().join("main_bin"); @@ -47,7 +58,7 @@ fn run(source: &str) -> (String, u64, u64, u64) { "binary failed ({:?})\nstderr:\n{stderr}", run.status ); - let count = |name: &str| -> u64 { + let count = move |name: &str| -> u64 { stderr .split_whitespace() .find_map(|w| w.strip_prefix(name)?.strip_prefix('=')?.parse().ok()) @@ -55,9 +66,7 @@ fn run(source: &str) -> (String, u64, u64, u64) { }; ( String::from_utf8_lossy(&run.stdout).trim().to_owned(), - count("primes_own"), - count("primes_inherited"), - count("misses"), + count, ) } @@ -350,18 +359,18 @@ console.log(s); ); } -/// `F.m()`: a function object's own properties live in a side table, not a shaped record, so -/// the site never memoizes one and every call takes the dispatcher behind the miss — with the -/// method reassigned, a key added and deleted mid-loop, results stay node's. (Serving these -/// from the site waits for functions to carry a shaped property record.) +/// `F.m()`: a function object's own methods live in its own-property object, an inline slot +/// the keyed Function ShapeId pins, so the site serves them from a function-bag entry (bit 61) +/// — with the method reassigned (same shape, new body: the code-pointer compare misses), a key +/// added (new keyed shape) and deleted (FunctionDictionary: never memoized) mid-loop, results +/// stay node's. Sabotage: serve the bag entry without the code-pointer compare, or read the +/// receiver's inline slot instead of the bag's -> the output changes. #[test] -fn function_object_receivers_keep_the_dispatcher() { - let (stdout, own, inherited, misses) = run( +fn function_object_receivers_are_served_from_their_property_object() { + let (stdout, count) = run_counted( r#"// ONE site over function-object receivers (`F.m()`), with the method // reassigned, a key added (new keyed shape), a key deleted (dictionary), and a -// namespace-style function carrying several methods. (Calling a DELETED -// method on a function object returns {} instead of throwing on base too — -// reported separately; not exercised here.) +// namespace-style function carrying several methods. const N = process.argv.length > 99 ? 1 : 6000; function F() { return 0; } (F as any).k = 5; @@ -389,9 +398,21 @@ console.log(s, out.join(",")); stdout, r#"29838000 50,6,2050,3008,4050,6008,-3000,9008,-4000,12008,5001,15008"# ); + let (own, inherited, function, misses) = ( + count("primes_own"), + count("primes_inherited"), + count("primes_function"), + count("misses"), + ); + assert!( + own == 0 && inherited == 0 && function >= 2, + "function receivers prime function-bag entries only (own={own} inherited={inherited} function={function})" + ); + // F leaves its keyed shape at i=4000 (a delete: FunctionDictionary), so + // its last 1000 calls miss by design; everything else is served inline. assert!( - own == 0 && inherited == 0 && misses >= 6000, - "a function-object receiver must not be memoized (own={own} inherited={inherited} misses={misses})" + misses < 2000, + "the hot calls must be served inline (function={function} misses={misses})" ); } From c3a5ee122bc47eb6c017d34fedb10de94efcec99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 08:33:25 +0000 Subject: [PATCH 08/12] test: drop the closure young-walk diagnostics test; the walk it counted is gone closure_walks_count_distinct_owners_consistently (#11443) counted the owners of the three closure side tables in the closure.dynamic_props young-log walk. A function's own properties, deleted keys and recorded prototype now live in its own-property object, a traced child of the function, so there is no side table, no young log and no walk to count. --- .../src/gc/tests/young_log_tests.rs | 30 ------------------- 1 file changed, 30 deletions(-) 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 c3bf9b2f63..b7509f2ad6 100644 --- a/crates/perry-runtime/src/gc/tests/young_log_tests.rs +++ b/crates/perry-runtime/src/gc/tests/young_log_tests.rs @@ -1002,33 +1002,3 @@ fn old_layout_records_are_skipped_by_a_minor() { crate::gc::layout_clear_for_ptr(owner as usize); } -#[test] -fn closure_walks_count_distinct_owners_consistently() { - let _guard = CopyingNurseryTestGuard::new(0); - gc_register_mutable_root_scanner(crate::closure::scan_closure_dynamic_props_roots_mut); - let owner = old_closure(); - crate::closure::closure_set_dynamic_prop(owner, "count", 42.0); - crate::closure::closure_set_static_prototype(owner, crate::value::TAG_NULL); - crate::closure::closure_mark_key_deleted(owner, "name"); - let other = old_closure(); - crate::closure::closure_mark_key_deleted(other, "length"); - - let mut mark = |_value: f64| {}; - let mut visitor = RuntimeRootVisitor::for_copy(&mut mark); - crate::closure::scan_closure_dynamic_props_roots_mut(&mut visitor); - let full = walk("closure.dynamic_props"); - assert!(!full.partial); - assert_eq!(full.table_len, 2); - assert_eq!(full.logged, 2); - assert_eq!(full.visited, 2); - - let _ = gc_collect_minor(); - let young = walk("closure.dynamic_props"); - assert!(young.partial); - assert_eq!( - young.table_len, full.table_len, - "one owner in three maps still counts once" - ); - assert_eq!(young.logged, 0); - assert_eq!(young.visited, 0); -} From b8df2389841ee703232e7df7abccc77c5e1b13c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 09:40:23 +0000 Subject: [PATCH 09/12] chore: gate findings after the rebase onto the method-call site - gc_runtime_root_holders: VERDICT_CACHE (keyed Function ShapeId, verdict bits) is not a GC pointer; recorded with its reason (rule T now covers Perry TLS declarations). - rustfmt after the young-walk test removal. - thread_exit_address_globals: the three closure side tables (CLOSURE_PROPS, CLOSURE_DELETED_KEYS, CLOSURE_STATIC_PROTOTYPES) are gone, so their thread-exit entries are removed. --- .../src/gc/tests/young_log_tests.rs | 1 - scripts/gc_runtime_root_holders.json | 6 +++++ scripts/thread_exit_address_globals.json | 27 ------------------- 3 files changed, 6 insertions(+), 28 deletions(-) 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 b7509f2ad6..834c0376e2 100644 --- a/crates/perry-runtime/src/gc/tests/young_log_tests.rs +++ b/crates/perry-runtime/src/gc/tests/young_log_tests.rs @@ -1001,4 +1001,3 @@ fn old_layout_records_are_skipped_by_a_minor() { crate::gc::layout_clear_for_ptr(owner as usize); } - diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index b1b84027b4..3db73aa0c0 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -45,6 +45,12 @@ "verdict": "not_a_gc_pointer", "why": "One-entry closure-birth cache: (func_ptr code address, ShapeId). A code address and an id; nothing for the collector." }, + { + "file": "crates/perry-runtime/src/closure/shape.rs", + "name": "VERDICT_CACHE", + "verdict": "not_a_gc_pointer", + "why": "Per-agent (keyed Function ShapeId u32, verdict bits u8) pairs for the Function.prototype bind/call/apply arm. Ids and flag bits, never an address; ShapeIds are never reused, so an entry can go unused but not stale." + }, { "file": "crates/perry-runtime/src/closure/shape.rs", "name": "FUNCTION_PROTOTYPE_SLOT", diff --git a/scripts/thread_exit_address_globals.json b/scripts/thread_exit_address_globals.json index 46cb244801..db97d8f5b0 100644 --- a/scripts/thread_exit_address_globals.json +++ b/scripts/thread_exit_address_globals.json @@ -1535,15 +1535,6 @@ "verdict": "process_global_allocation", "why": "Sampled allocation-site table; `live` maps pointers returned by the process GlobalAlloc (CensusAlloc wrapping the system/mimalloc allocator, alloc_census.rs:232) to site ids, and record_free (:265) removes the entry on every dealloc — including arena blocks released through the allocator — so addresses are never stale." }, - { - "file": "crates/perry-runtime/src/closure/dynamic_props.rs", - "names": [ - "CLOSURE_PROPS" - ], - "verdict": "thread_exit_invalidated", - "why": "Closure-address -> dynamic props map written by closure property sets on any thread; entries whose owner lies in the exiting arena's blocks are dropped by release_closure_side_table_owners_in_ranges (dynamic_props.rs:465), called from Arena::drop (arena/block.rs:539).", - "hook": "release_closure_side_table_owners_in_ranges" - }, { "file": "crates/perry-runtime/src/closure/dynamic_props.rs", "names": [ @@ -1553,24 +1544,6 @@ "why": "(wasm-host feature) closure-address -> host funcref handle map; entries in the exiting arena's ranges are removed and their handles dropped by release_closure_side_table_owners_in_ranges (dynamic_props.rs:474-491) from Arena::drop.", "hook": "release_closure_side_table_owners_in_ranges" }, - { - "file": "crates/perry-runtime/src/closure/dynamic_props.rs", - "names": [ - "CLOSURE_DELETED_KEYS" - ], - "verdict": "thread_exit_invalidated", - "why": "Closure-address -> deleted property names, written by closure_mark_key_deleted; purged over the exiting arena's ranges by release_closure_side_table_owners_in_ranges (dynamic_props.rs:471) from Arena::drop.", - "hook": "release_closure_side_table_owners_in_ranges" - }, - { - "file": "crates/perry-runtime/src/closure/dynamic_props.rs", - "names": [ - "CLOSURE_STATIC_PROTOTYPES" - ], - "verdict": "thread_exit_invalidated", - "why": "Closure-address -> NaN-boxed prototype bits from Object.setPrototypeOf(fn, proto); purged by owner range in release_closure_side_table_owners_in_ranges (dynamic_props.rs:468, via get_closure_prototypes) from Arena::drop.", - "hook": "release_closure_side_table_owners_in_ranges" - }, { "file": "crates/perry-runtime/src/typedarray/mod.rs", "names": [ From 55a87a37cb7bcef0a344085302a4bd3c00200d9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 09:46:02 +0000 Subject: [PATCH 10/12] perf(runtime): a method-site miss on a function receiver primes only when the shape lists the key Every fn.bind / fn.call on a function object misses the method site (an inherited builtin is never memoized), and with function-bag entries each of those misses rooted the receiver, dispatched, and then refused in prime_function: +680 instructions per Zod bind. prime_candidate now asks the receiver's Function ShapeId whether its key list holds the name before the miss roots anything; bind/call/apply leave on that key-list probe, as they left on the GcHeader type before. --- .../perry-runtime/src/object/method_site.rs | 44 ++++++++++++++++--- crates/perry-runtime/src/object/shapes.rs | 8 ++++ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/crates/perry-runtime/src/object/method_site.rs b/crates/perry-runtime/src/object/method_site.rs index 4fea0b7244..2170bc2b21 100644 --- a/crates/perry-runtime/src/object/method_site.rs +++ b/crates/perry-runtime/src/object/method_site.rs @@ -316,7 +316,8 @@ pub unsafe extern "C-unwind" fn js_method_site_miss( // Only an ordinary heap object can prime. Everything else (primitives, // handles, functions, arrays) dispatches with no extra work at all. let megamorphic = site_is_megamorphic(slot); - if megamorphic || !prime_candidate(recv) { + if megamorphic || !prime_candidate(recv, std::slice::from_raw_parts(name_ref.ptr, name_ref.len)) + { refuse(if megamorphic { 18 } else { 1 }); return crate::typed_feedback::js_typed_feedback_native_call_method( site_id, @@ -355,18 +356,47 @@ pub unsafe extern "C-unwind" fn js_method_site_miss( } /// A cheap first cut of [`ordinary_receiver`] / [`prime_function`]: a heap -/// pointer whose GcHeader says ordinary object or function object. +/// pointer whose GcHeader says ordinary object, or a function object whose +/// SHAPE lists `name` as an own key. Most calls on functions (`fn.bind`, +/// `fn.call`) name an inherited builtin a site never memoizes; they leave +/// here on the shape's key list, before the miss roots anything. #[inline] -fn prime_candidate(recv: f64) -> bool { +fn prime_candidate(recv: f64, name: &[u8]) -> bool { let bits = recv.to_bits(); if bits & !crate::value::POINTER_MASK != crate::value::POINTER_TAG { return false; } let addr = (bits & crate::value::POINTER_MASK) as usize; - crate::value::addr_class::is_above_handle_band(addr) - && unsafe { crate::value::addr_class::try_read_gc_header(addr) }.is_some_and(|h| { - h.obj_type == crate::gc::GC_TYPE_OBJECT || h.obj_type == crate::gc::GC_TYPE_CLOSURE - }) + if !crate::value::addr_class::is_above_handle_band(addr) { + return false; + } + match unsafe { crate::value::addr_class::try_read_gc_header(addr) } { + Some(h) if h.obj_type == crate::gc::GC_TYPE_OBJECT => true, + Some(h) if h.obj_type == crate::gc::GC_TYPE_CLOSURE => unsafe { + function_shape_lists_key(addr, name) + }, + _ => false, + } +} + +/// Does the (claimed) function object at `addr` sit on a KEYED Function shape +/// whose key list holds `name`? Reads the ShapeId word and the shape's key +/// list only; [`prime_function`] re-proves ownership before trusting it. +#[inline] +unsafe fn function_shape_lists_key(addr: usize, name: &[u8]) -> bool { + let id = *((addr as *const u8).add(crate::closure::CLOSURE_SHAPE_OFFSET) as *const u32); + if !super::shapes::is_exotic_shape_id(id) + || id == crate::closure::shape::function_dictionary_shape() + { + return false; + } + // The record in place (no descriptor copy): its key list and count. + let Some(record) = super::shapes::shape_record_by_id(id) else { + return false; + }; + let keys = record.keys() as usize as *const crate::array::ArrayHeader; + !keys.is_null() + && super::keys_find_slot_by_bytes_resolved(keys, record.logical_key_count(), name).is_some() } unsafe fn site_of(slot: *mut MethodSiteSlot) -> *mut MethodSite { diff --git a/crates/perry-runtime/src/object/shapes.rs b/crates/perry-runtime/src/object/shapes.rs index fd346943c2..2a62a4a1e9 100644 --- a/crates/perry-runtime/src/object/shapes.rs +++ b/crates/perry-runtime/src/object/shapes.rs @@ -208,6 +208,14 @@ impl ShapeRecordRef { unsafe { (*self.0.as_ptr()).summary() } } + /// The record's logical key count (how many entries of `keys` the shape + /// describes). + #[inline] + pub(crate) fn logical_key_count(self) -> u32 { + // SAFETY: a live slab record (type docs). + unsafe { (*self.0.as_ptr()).logical_key_count } + } + /// The record's current `keys` word (0 for a keyless shape). #[inline] pub(crate) fn keys(self) -> u64 { From 1cad3e2751356e837450beef6a7392af551ffc40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 12:55:04 +0000 Subject: [PATCH 11/12] docs(changelog): a function's own properties live in the function object --- ...function-own-properties-in-the-function.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 changelog.d/function-own-properties-in-the-function.md diff --git a/changelog.d/function-own-properties-in-the-function.md b/changelog.d/function-own-properties-in-the-function.md new file mode 100644 index 0000000000..f908e22cd3 --- /dev/null +++ b/changelog.d/function-own-properties-in-the-function.md @@ -0,0 +1,21 @@ +A function's own properties now live in the function object: an ordinary +shaped property object hangs off the closure header, traced by the collector +and installed with a write barrier. The three process-global closure side +tables (own properties, deleted keys, recorded prototypes), their young log +and their rekey/prune/scan passes are deleted. A function with only data +properties gets a keyed Function shape that is canonical per its property +object's key list. + +The method-call site serves `F.m()` on such a function from that property +object (a new entry kind), so a namespace-style function's methods are called +directly like an ordinary object's. + +Fixed, matching node: `bind` reads `length` through a getter; a deleted +`name` / `length` is inherited from `Function.prototype`; after +`Object.setPrototypeOf(fn, p)`, `fn.call` / `apply` / `bind` use `p`'s; and +calling a method a function object does not have (never set, deleted, or +absent from its prototype) throws a TypeError instead of returning `{}`. + +Measured against the method-call base (instructions, real release profile, +5 interleaved rounds, each arm linking its own runtime): the Zod workload +1.609G -> 1.143G (-29.0%), the tsc workload 79.57G -> 78.03G (-1.9%). From 47811d3c8774701d0851205328ecc319530839eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 21:53:24 +0200 Subject: [PATCH 12/12] changelog: name the fragment after PR #11581 --- ...nction.md => 11581-function-own-properties-in-the-function.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{function-own-properties-in-the-function.md => 11581-function-own-properties-in-the-function.md} (100%) diff --git a/changelog.d/function-own-properties-in-the-function.md b/changelog.d/11581-function-own-properties-in-the-function.md similarity index 100% rename from changelog.d/function-own-properties-in-the-function.md rename to changelog.d/11581-function-own-properties-in-the-function.md