From 20f8c747b1577f37ea744bba5373c154fe292b40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 06:48:10 +0000 Subject: [PATCH 1/3] fix(runtime): run inherited Symbol-keyed accessors with the original receiver An inherited Symbol-keyed accessor (Object.defineProperty(Fn.prototype, sym, ...), an object-literal get [sym]() reached through Object.create, or a declared class prototype) ran its getter/setter with no receiver at all, so it observed whatever this happened to be ambient. fastify 5.10.0's Reply.prototype[kRouteContext] getter crashed every HTTP request with TypeError: Cannot read properties of undefined (reading 'request'). own_symbol_property now resolves to an OwnSymbolSlot (Accessor or Data) before deciding how to read it, so every prototype-chain walk (resolve_proto_chain_symbol, explicit_prototype_symbol_slot, declared_prototype_chain_symbol) can thread the read/write's actual receiver through to the accessor invocation. Reflect.get/set for a Symbol key now reach the receiver-aware entry points directly. An inherited SETTER is now consulted too - obj[sym] = v used to silently shadow it with a new own data property instead of running it - gated by a symbol-id-keyed accessor filter (symbol_may_have_accessor) so the common no-accessor write path stays cheap. --- .../src/object/class_registry.rs | 2 +- .../class_registry/prototype_objects.rs | 23 +- crates/perry-runtime/src/proxy.rs | 13 ++ crates/perry-runtime/src/proxy/reflect.rs | 12 + crates/perry-runtime/src/symbol.rs | 8 +- crates/perry-runtime/src/symbol/accessors.rs | 82 +++++++ crates/perry-runtime/src/symbol/get.rs | 216 ++++++++++++++---- .../src/symbol/inherited_accessor_tests.rs | 211 +++++++++++++++++ crates/perry-runtime/src/symbol/properties.rs | 26 ++- ..._10481_inherited_symbol_getter_receiver.ts | 173 ++++++++++++++ 10 files changed, 704 insertions(+), 62 deletions(-) create mode 100644 crates/perry-runtime/src/symbol/inherited_accessor_tests.rs create mode 100644 test-files/test_gap_10481_inherited_symbol_getter_receiver.ts diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index 6afe9c9bf7..cc819c65b7 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -96,7 +96,7 @@ pub use state::{ // ── prototype_objects.rs ──────────────────────────────────────────────────── pub(crate) use prototype_objects::{ class_prototype_object, ensure_function_prototype_object, function_class_id, - function_value_for_class_id, resolve_proto_chain_field, + function_value_for_class_id, proto_chain_symbol_slot, resolve_proto_chain_field, resolve_proto_chain_field_with_receiver, resolve_proto_chain_symbol, }; pub use prototype_objects::{ diff --git a/crates/perry-runtime/src/object/class_registry/prototype_objects.rs b/crates/perry-runtime/src/object/class_registry/prototype_objects.rs index d5c41c34bb..921b745c02 100644 --- a/crates/perry-runtime/src/object/class_registry/prototype_objects.rs +++ b/crates/perry-runtime/src/object/class_registry/prototype_objects.rs @@ -657,7 +657,24 @@ unsafe fn resolve_proto_chain_field_inner( /// At each node we follow the proto object's own class id (the /// `Object.create` prototype link) first, then fall back to /// `parent_class_id` (the `extends` link); a `visited` set bounds cycles. -pub(crate) unsafe fn resolve_proto_chain_symbol(class_id: u32, sym_f64: f64) -> Option { +/// +/// #10481: an accessor found on a prototype object runs with `this === +/// receiver`, the object the read started from — never the prototype object +/// that holds it. +pub(crate) unsafe fn resolve_proto_chain_symbol( + class_id: u32, + sym_f64: f64, + receiver: f64, +) -> Option { + proto_chain_symbol_slot(class_id, sym_f64).map(|slot| slot.read(receiver)) +} + +/// The walk behind [`resolve_proto_chain_symbol`], stopping at the nearest +/// prototype object that owns `sym_f64` without invoking an accessor there. +pub(crate) unsafe fn proto_chain_symbol_slot( + class_id: u32, + sym_f64: f64, +) -> Option { let mut cid = class_id; let mut depth = 0usize; let mut visited: [u32; 32] = [0; 32]; @@ -672,8 +689,8 @@ pub(crate) unsafe fn resolve_proto_chain_symbol(class_id: u32, sym_f64: f64) -> let proto_f64 = f64::from_bits(JSValue::pointer(proto_obj as *const u8).bits()); // OWN lookup only — this fn IS the chain walk, so recursing into // the full chain-walking getter would re-walk per prototype. - if let Some(v) = crate::symbol::own_symbol_property(proto_f64, sym_f64) { - return Some(v); + if let Some(slot) = crate::symbol::own_symbol_slot(proto_f64, sym_f64) { + return Some(slot); } // Prefer the `Object.create` prototype link: the next chain node // is the proto object's own class id (which maps to ITS proto in diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index f90c9d087c..e8b03c4269 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -1363,6 +1363,19 @@ fn own_set_descriptor(target: f64, key: f64) -> Option { if !unsafe { crate::symbol::has_own_symbol_property(target, key) } { return None; } + // #10481: a symbol ACCESSOR is not a data slot — report its setter so + // the walk runs it with the receiver instead of shadowing it. + let (owner, sym_key) = unsafe { + ( + crate::symbol::obj_key_from_f64(target), + crate::symbol::sym_key_from_f64(key), + ) + }; + if let Some((_, setter_bits)) = + crate::symbol::symbol_accessor_descriptor_bits(owner, sym_key) + { + return Some(OwnSetDescriptor::Accessor { setter_bits }); + } // An existing symbol-keyed own data property is non-writable when the // receiver is frozen or its per-symbol attrs say so — so a strict // `obj[sym] = v` is rejected (throws) rather than silently no-op'd diff --git a/crates/perry-runtime/src/proxy/reflect.rs b/crates/perry-runtime/src/proxy/reflect.rs index 646e934456..c61a1724ec 100644 --- a/crates/perry-runtime/src/proxy/reflect.rs +++ b/crates/perry-runtime/src/proxy/reflect.rs @@ -97,6 +97,18 @@ pub extern "C" fn js_reflect_get(target: f64, key: f64, receiver: f64) -> f64 { } else { receiver_handle.get_nanbox_f64() }; + // #10481: the Symbol resolver invokes every accessor it finds with an + // explicit receiver, so hand it the one this call was given; an inherited + // getter must not see the target the lookup started from. + if unsafe { crate::symbol::js_is_symbol(property_key_handle.get_nanbox_f64()) } != 0 { + return unsafe { + crate::symbol::js_object_get_symbol_property_with_receiver( + target_handle.get_nanbox_f64(), + property_key_handle.get_nanbox_f64(), + recv, + ) + }; + } let prev = this_scope.root_nanbox_f64(crate::object::js_implicit_this_set(recv)); let result = target_get_property_key( target_handle.get_nanbox_f64(), diff --git a/crates/perry-runtime/src/symbol.rs b/crates/perry-runtime/src/symbol.rs index 5bab20ae26..fb3375b629 100644 --- a/crates/perry-runtime/src/symbol.rs +++ b/crates/perry-runtime/src/symbol.rs @@ -27,6 +27,8 @@ pub(crate) use accessors::{ mod constructors; mod gc_roots; mod get; +#[cfg(test)] +mod inherited_accessor_tests; mod iterator; mod properties; @@ -56,7 +58,11 @@ pub use properties::{ // Symbol-keyed property reads. pub(crate) use get::has_declared_prototype_symbol_property; -pub(crate) use get::{has_own_symbol_property, inherited_symbol_property, own_symbol_property}; +pub(crate) use get::{ + has_own_symbol_property, inherited_symbol_property, + js_object_get_symbol_property_with_receiver, own_symbol_property, own_symbol_slot, + OwnSymbolSlot, +}; pub use get::{ js_object_get_symbol_property, js_object_get_symbol_property_ic_miss, js_object_get_symbol_then_field_ic_miss, SymbolPicCache, SymbolPicCacheSlot, SYMBOL_PIC_WORDS, diff --git a/crates/perry-runtime/src/symbol/accessors.rs b/crates/perry-runtime/src/symbol/accessors.rs index 9ceeddbe3c..f0cdcd9203 100644 --- a/crates/perry-runtime/src/symbol/accessors.rs +++ b/crates/perry-runtime/src/symbol/accessors.rs @@ -78,6 +78,66 @@ pub(crate) fn test_seed_symbol_accessor_property(obj_key: usize, sym_key: usize, ); } +/// One bit per symbol that has ever carried an accessor, hashed by the +/// symbol's ID (#10481). `obj[sym] = v` consults it before walking the +/// prototype chain for an inherited setter: a clear bit proves no accessor +/// exists under that symbol anywhere, so the walk cannot find one and the +/// write goes straight to the own-data store. A set bit is only a maybe, and +/// costs the walk that a correct answer needed anyway. +/// +/// Keyed by `SymbolHeader::id`, not by the symbol's ADDRESS: a moving +/// collection rewrites the accessor table's pointer keys (see +/// `scan_symbol_accessor_roots_mut`) but copies the id verbatim, so an +/// id-keyed filter needs no rescan, no rekey and no GC root. Monotonic — +/// removing an accessor leaves the bit set, which only costs a walk. +static SYMBOL_ACCESSOR_IDS: [std::sync::atomic::AtomicU64; SYMBOL_ACCESSOR_ID_WORDS] = + [const { std::sync::atomic::AtomicU64::new(0) }; SYMBOL_ACCESSOR_ID_WORDS]; +const SYMBOL_ACCESSOR_ID_WORDS: usize = 4; + +/// `(word, mask)` for a symbol's id. Ids are a monotonic counter, so the low +/// bits discriminate perfectly until the filter saturates at 256 distinct +/// accessor symbols. +/// +/// # Safety +/// `sym_key` must be a live `SymbolHeader` address (a non-zero +/// `sym_key_from_f64` result, which has already checked the magic). +#[inline] +unsafe fn symbol_accessor_id_bit(sym_key: usize) -> (usize, u64) { + let id = (*(sym_key as *const crate::symbol::SymbolHeader)).id; + let bit = (id % (SYMBOL_ACCESSOR_ID_WORDS as u64 * 64)) as usize; + (bit / 64, 1u64 << (bit % 64)) +} + +/// `false` ⟹ no accessor has ever been installed under this symbol, on any +/// object. Checked before the inherited-accessor walk on the write path. +/// +/// # Safety +/// Same contract as [`symbol_accessor_id_bit`]. +#[inline] +pub(super) unsafe fn symbol_may_have_accessor(sym_key: usize) -> bool { + let (word, mask) = symbol_accessor_id_bit(sym_key); + SYMBOL_ACCESSOR_IDS[word].load(std::sync::atomic::Ordering::Acquire) & mask != 0 +} + +/// Record that `sym_key` carries an accessor. Published BEFORE the table +/// insert, so a reader that sees a clear bit cannot miss the entry. +/// +/// # Safety +/// Same contract as [`symbol_accessor_id_bit`]. +#[inline] +unsafe fn note_symbol_accessor_key(sym_key: usize) { + let (word, mask) = symbol_accessor_id_bit(sym_key); + SYMBOL_ACCESSOR_IDS[word].fetch_or(mask, std::sync::atomic::Ordering::AcqRel); +} + +#[cfg(test)] +pub(crate) fn test_symbol_accessor_id_bits_set() -> u32 { + SYMBOL_ACCESSOR_IDS + .iter() + .map(|w| w.load(std::sync::atomic::Ordering::Acquire).count_ones()) + .sum() +} + pub(crate) unsafe fn set_symbol_accessor_property( obj_f64: f64, sym_f64: f64, @@ -90,6 +150,7 @@ pub(crate) unsafe fn set_symbol_accessor_property( return; } crate::symbol::note_symbol_key_installed(sym_key); + note_symbol_accessor_key(sym_key); { // `SYMBOL_PROPERTIES` is the only insertion-ordered record of symbol // property CREATION order, which `[[OwnPropertyKeys]]` must report @@ -161,6 +222,27 @@ pub(super) fn symbol_accessor_property_by_key( .and_then(|m| m.get(&(obj_key, sym_key)).copied()) } +/// Setter twin of [`invoke_symbol_accessor_getter`] (#10481): runs `set_bits` +/// with `this === receiver` and returns the assigned value. The value is +/// re-read from its root afterwards — the setter body is user code, so a +/// collection inside it can move whatever `value` points at. +pub(super) unsafe fn invoke_symbol_accessor_setter( + set_bits: u64, + receiver: f64, + value: f64, +) -> f64 { + let closure = (set_bits & crate::value::POINTER_MASK) as *const crate::closure::ClosureHeader; + if set_bits == 0 || closure.is_null() { + return value; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let value_h = scope.root_nanbox_f64(value); + let prev = scope.root_nanbox_f64(crate::object::js_implicit_this_set(receiver)); + crate::closure::js_closure_call1(closure, value_h.get_nanbox_f64()); + crate::object::js_implicit_this_set(prev.get_nanbox_f64()); + value_h.get_nanbox_f64() +} + pub(super) unsafe fn invoke_symbol_accessor_getter(get_bits: u64, receiver: f64) -> f64 { if get_bits == 0 { return f64::from_bits(TAG_UNDEFINED); diff --git a/crates/perry-runtime/src/symbol/get.rs b/crates/perry-runtime/src/symbol/get.rs index ed72157767..4b9341255a 100644 --- a/crates/perry-runtime/src/symbol/get.rs +++ b/crates/perry-runtime/src/symbol/get.rs @@ -95,15 +95,55 @@ pub(crate) unsafe fn has_own_symbol_property(obj_f64: f64, sym_f64: f64) -> bool /// `resolve_proto_chain_symbol`, which walks prototype objects itself and must /// therefore NOT recurse into the full chain-walking getter. pub(crate) unsafe fn own_symbol_property(obj_f64: f64, sym_f64: f64) -> Option { - if let Some(acc) = accessors::symbol_accessor_property(obj_f64, sym_f64) { - if acc.get != 0 { - let closure = - (acc.get & crate::value::POINTER_MASK) as *const crate::closure::ClosureHeader; - if !closure.is_null() { - return Some(crate::closure::js_closure_call0(closure)); + own_symbol_property_for_receiver(obj_f64, sym_f64, obj_f64) +} + +/// #10481: [`own_symbol_property`] on `obj_f64` for a `[[Get]]` whose +/// receiver is `receiver` — the object the read started from, which differs +/// from `obj_f64` whenever a prototype walk found the property on an ancestor. +/// An accessor's getter runs with `this === receiver` (spec `[[Get]](P, +/// Receiver)`). The getter used to be called with no receiver at all, so it +/// observed whatever `IMPLICIT_THIS` the caller happened to leave behind: +/// fastify's inherited `Reply.prototype[kRouteContext]` getter saw +/// `this === undefined` on every request. +pub(crate) unsafe fn own_symbol_property_for_receiver( + obj_f64: f64, + sym_f64: f64, + receiver: f64, +) -> Option { + own_symbol_slot(obj_f64, sym_f64).map(|slot| slot.read(receiver)) +} + +/// An own symbol-keyed property as stored, before any accessor runs (#10481). +/// Lets a prototype walk locate the holder once and leave the decision to its +/// caller: a `[[Get]]` reads it for the original receiver, a `[[Set]]` runs +/// an accessor's setter or stops at a data property. +#[derive(Clone, Copy)] +pub(crate) enum OwnSymbolSlot { + Accessor { get: u64, set: u64 }, + Data(u64), +} + +impl OwnSymbolSlot { + /// `[[Get]]` of this property with `this === receiver`. + pub(crate) unsafe fn read(self, receiver: f64) -> f64 { + match self { + OwnSymbolSlot::Accessor { get, .. } => { + accessors::invoke_symbol_accessor_getter(get, receiver) } + OwnSymbolSlot::Data(bits) => f64::from_bits(bits), } - return Some(f64::from_bits(TAG_UNDEFINED)); + } +} + +/// The two lookups [`has_own_symbol_property`] mirrors (accessor table, then +/// the raw `SYMBOL_PROPERTIES` data table), returning what they found. +pub(crate) unsafe fn own_symbol_slot(obj_f64: f64, sym_f64: f64) -> Option { + if let Some(acc) = accessors::symbol_accessor_property(obj_f64, sym_f64) { + return Some(OwnSymbolSlot::Accessor { + get: acc.get, + set: acc.set, + }); } let obj_key = obj_key_from_f64(obj_f64); let sym_key = sym_key_from_f64(sym_f64); @@ -115,7 +155,7 @@ pub(crate) unsafe fn own_symbol_property(obj_f64: f64, sym_f64: f64) -> Option Option<(usize, u8)> { Some((raw, (*gc_header).obj_type)) } -/// Walk the explicit static prototype chain to find an inherited symbol property. -/// Used by `Object.prototype.toString` to implement the spec's -/// `Get(O, @@toStringTag)` prototype-chain walk. +/// Walk the prototype chains to find an inherited symbol property. Used by +/// `Object.prototype.toString` to implement the spec's `Get(O, @@toStringTag)` +/// prototype-chain walk; an accessor runs with `this === obj_f64` (#10481). pub(crate) unsafe fn inherited_symbol_property(obj_f64: f64, sym_f64: f64) -> Option { - resolve_explicit_object_prototype_symbol(obj_f64, sym_f64) + inherited_symbol_slot(obj_f64, sym_f64).map(|slot| slot.read(obj_f64)) } -unsafe fn resolve_explicit_object_prototype_symbol(obj_f64: f64, sym_f64: f64) -> Option { +/// `receiver` is the `this` an inherited accessor runs with (#10481). +unsafe fn resolve_explicit_object_prototype_symbol( + obj_f64: f64, + sym_f64: f64, + receiver: f64, +) -> Option { + explicit_prototype_symbol_slot(obj_f64, sym_f64).map(|slot| slot.read(receiver)) +} + +/// The explicit-static-prototype walk behind +/// [`resolve_explicit_object_prototype_symbol`], stopping at the nearest +/// holder without invoking it. +unsafe fn explicit_prototype_symbol_slot(obj_f64: f64, sym_f64: f64) -> Option { const TAG_NULL: u64 = 0x7FFC_0000_0000_0002; // #9192: the receiver may be a real ARRAY with a retargeted `[[Prototype]]` // (`Object.setPrototypeOf(arr, {[S]: v})`). Its address is only a lookup @@ -426,9 +478,8 @@ unsafe fn resolve_explicit_object_prototype_symbol(obj_f64: f64, sym_f64: f64) - if proto_bits == TAG_NULL { return None; } - let proto_f64 = f64::from_bits(proto_bits); - if let Some(v) = own_symbol_property(proto_f64, sym_f64) { - return Some(v); + if let Some(slot) = own_symbol_slot(f64::from_bits(proto_bits), sym_f64) { + return Some(slot); } let proto_ptr = object_header_ptr_from_value_bits(proto_bits)?; // Cycle detection. @@ -450,8 +501,8 @@ unsafe fn resolve_explicit_object_prototype_symbol(obj_f64: f64, sym_f64: f64) - let proto_obj = proto_ptr as *const crate::object::ObjectHeader; let cid = crate::object::js_object_get_class_id(proto_obj); if cid != 0 { - if let Some(v) = crate::object::resolve_proto_chain_symbol(cid, sym_f64) { - return Some(v); + if let Some(slot) = crate::object::proto_chain_symbol_slot(cid, sym_f64) { + return Some(slot); } } owner = proto_ptr; @@ -520,6 +571,19 @@ unsafe fn web_stream_symbol_property(obj_f64: f64, sym_f64: f64) -> Option #[no_mangle] pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f64) -> f64 { + js_object_get_symbol_property_with_receiver(obj_f64, sym_f64, obj_f64) +} + +/// `[[Get]](sym_f64, receiver_f64)` on `obj_f64`: the property is resolved on +/// `obj_f64` and its prototype chain, and every accessor found along the way +/// runs with `this === receiver_f64` (#10481). `Reflect.get(target, sym, +/// receiver)` is the caller whose receiver differs from the holder of the +/// lookup; every ordinary `obj[sym]` read passes `obj_f64` itself. +pub(crate) unsafe fn js_object_get_symbol_property_with_receiver( + obj_f64: f64, + sym_f64: f64, + receiver_f64: f64, +) -> f64 { #[cfg(feature = "regex-engine")] if crate::regex::is_registered_regex(crate::value::js_nanbox_get_pointer(obj_f64) as usize) { // RegExpHeader is not an ObjectHeader. Resolve its own symbols and @@ -527,13 +591,12 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 let scope = crate::gc::RuntimeHandleScope::new(); let receiver = scope.root_nanbox_f64(obj_f64); let symbol = scope.root_nanbox_f64(sym_f64); - if let Some(acc) = - accessors::symbol_accessor_property(receiver.get_nanbox_f64(), symbol.get_nanbox_f64()) - { - return accessors::invoke_symbol_accessor_getter(acc.get, receiver.get_nanbox_f64()); - } - if let Some(value) = own_symbol_property(receiver.get_nanbox_f64(), symbol.get_nanbox_f64()) - { + let this_h = scope.root_nanbox_f64(receiver_f64); + if let Some(value) = own_symbol_property_for_receiver( + receiver.get_nanbox_f64(), + symbol.get_nanbox_f64(), + this_h.get_nanbox_f64(), + ) { return value; } let proto = scope.root_nanbox_f64(crate::object::js_object_get_prototype_of( @@ -545,7 +608,7 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 return crate::proxy::js_reflect_get( proto.get_nanbox_f64(), symbol.get_nanbox_f64(), - receiver.get_nanbox_f64(), + this_h.get_nanbox_f64(), ); } // A Proxy is a small registered id (its band overlaps the small-handle @@ -566,12 +629,17 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 // installed `[Symbol.toPrimitive]`). This is the DateCell analogue of the // ordinary object's own-then-prototype symbol walk. if crate::date::is_date_value(obj_f64) { - if let Some(v) = own_symbol_property(obj_f64, sym_f64) { + if let Some(v) = own_symbol_property_for_receiver(obj_f64, sym_f64, receiver_f64) { return v; } + // Materializing `Date.prototype` can allocate; the receiver outlives it. + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver_h = scope.root_nanbox_f64(receiver_f64); let proto = crate::object::builtin_prototype_value("Date"); if (proto.to_bits() >> 48) == 0x7FFD { - if let Some(v) = own_symbol_property(proto, sym_f64) { + if let Some(v) = + own_symbol_property_for_receiver(proto, sym_f64, receiver_h.get_nanbox_f64()) + { return v; } } @@ -585,7 +653,7 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 let sym_key = sym_key_from_f64(sym_f64); if sym_key != 0 { if let Some(v) = - crate::object::class_symbol_getter_value(class_id, sym_key, obj_f64, true) + crate::object::class_symbol_getter_value(class_id, sym_key, receiver_f64, true) { return v; } @@ -640,7 +708,8 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 // #1758: a class ref whose own static symbols miss may inherit the // symbol from a class-expression parent (`class Sub extends make(...) {}` // → `Sub[TypeId]`). Walk the CLASS_PROTOTYPE_OBJECTS chain. - if let Some(v) = crate::object::resolve_proto_chain_symbol(class_id, sym_f64) { + if let Some(v) = crate::object::resolve_proto_chain_symbol(class_id, sym_f64, receiver_f64) + { return v; } // #36 / #321: the subclass extends a FUNCTION value @@ -822,7 +891,7 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 } } if let Some(acc) = accessors::symbol_accessor_property(obj_f64, sym_f64) { - return accessors::invoke_symbol_accessor_getter(acc.get, obj_f64); + return accessors::invoke_symbol_accessor_getter(acc.get, receiver_f64); } if let Some(v) = own_symbol_property(obj_f64, sym_f64) { return v; @@ -848,11 +917,13 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 let scope = crate::gc::RuntimeHandleScope::new(); let obj_h = scope.root_nanbox_f64(obj_f64); let sym_h = scope.root_nanbox_f64(sym_f64); + let receiver_h = scope.root_nanbox_f64(receiver_f64); if let Some(v) = req_handle_symbol_fallback(obj_h.get_nanbox_f64(), sym_h.get_nanbox_f64()) { return v; } let obj_f64 = obj_h.get_nanbox_f64(); let sym_f64 = sym_h.get_nanbox_f64(); + let receiver_f64 = receiver_h.get_nanbox_f64(); let bits = obj_f64.to_bits(); let sym_key = sym_key_from_f64(sym_f64); if sym_key != 0 { @@ -862,9 +933,12 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 if !ptr.is_null() && crate::object::is_valid_obj_ptr(ptr as *const u8) { let class_id = crate::object::js_object_get_class_id(ptr); if class_id != 0 { - if let Some(v) = - crate::object::class_symbol_getter_value(class_id, sym_key, obj_f64, false) - { + if let Some(v) = crate::object::class_symbol_getter_value( + class_id, + sym_key, + receiver_f64, + false, + ) { return v; } // #5128: a symbol-keyed instance METHOD — `*[Symbol.iterator]()` @@ -879,9 +953,12 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 if let Some(owner) = crate::object::method_owner_class_id(class_id, method_name) { - if let Some(value) = - class_iterator_prototype_override(obj_f64, sym_f64, class_id, owner) - { + if let Some(value) = class_iterator_prototype_override( + receiver_f64, + sym_f64, + class_id, + owner, + ) { return value; } return crate::object::js_class_method_bind( @@ -926,7 +1003,7 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 } } } - if let Some(v) = resolve_explicit_object_prototype_symbol(obj_f64, sym_f64) { + if let Some(v) = resolve_explicit_object_prototype_symbol(obj_f64, sym_f64, receiver_f64) { return v; } // `class X extends Map | Set` instance — its default `[Symbol.iterator]` @@ -1016,7 +1093,8 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 if proto_ptr == 0 || proto_ptr == cur { break; } - if let Some(v) = own_symbol_property(proto_f64, sym_f64) { + if let Some(v) = own_symbol_property_for_receiver(proto_f64, sym_f64, receiver_f64) + { return v; } // A class-object proto may carry the symbol through ITS own @@ -1027,7 +1105,9 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 if !proto_obj.is_null() { let cid = crate::object::js_object_get_class_id(proto_obj); if cid != 0 { - if let Some(v) = crate::object::resolve_proto_chain_symbol(cid, sym_f64) { + if let Some(v) = + crate::object::resolve_proto_chain_symbol(cid, sym_f64, receiver_f64) + { return v; } } @@ -1054,7 +1134,9 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 if ptr != 0 && crate::closure::is_closure_ptr(ptr) { let func_proto = crate::object::builtin_prototype_value("Function"); if (func_proto.to_bits() >> 48) == 0x7FFD { - if let Some(v) = own_symbol_property(func_proto, sym_f64) { + // Re-read: materializing `Function.prototype` can allocate. + let receiver = receiver_h.get_nanbox_f64(); + if let Some(v) = own_symbol_property_for_receiver(func_proto, sym_f64, receiver) { return v; } } @@ -1196,7 +1278,9 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 if !obj_ptr.is_null() { let cid = crate::object::js_object_get_class_id(obj_ptr); if cid != 0 { - if let Some(v) = crate::object::resolve_proto_chain_symbol(cid, sym_f64) { + if let Some(v) = + crate::object::resolve_proto_chain_symbol(cid, sym_f64, receiver_f64) + { return v; } // A symbol-keyed property added to a DECLARED class's @@ -1210,7 +1294,9 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 // so drizzle-orm's `applyEffectWrapper` (effect's // `Effectable.Prototype` assigned onto query classes) left // `yield* query` with no iterator ("next is not a function"). - if let Some(v) = declared_prototype_chain_symbol(obj_f64, sym_f64, cid) { + if let Some(v) = + declared_prototype_chain_symbol(obj_f64, sym_f64, cid, receiver_f64) + { return v; } // #1838: a class can define a computed well-known-symbol METHOD @@ -1242,12 +1328,14 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 /// Accessors run with the original receiver; data properties are returned as /// stored. Nearest class first, so a subclass's prototype write shadows a /// base class's. -unsafe fn declared_prototype_chain_symbol(receiver: f64, sym: f64, class_id: u32) -> Option { - let holder = declared_prototype_symbol_holder(receiver, sym, class_id)?; - if let Some(acc) = accessors::symbol_accessor_property(holder, sym) { - return Some(accessors::invoke_symbol_accessor_getter(acc.get, receiver)); - } - own_symbol_property(holder, sym) +unsafe fn declared_prototype_chain_symbol( + obj: f64, + sym: f64, + class_id: u32, + receiver: f64, +) -> Option { + let holder = declared_prototype_symbol_holder(obj, sym, class_id)?; + own_symbol_property_for_receiver(holder, sym, receiver) } /// Locate a declared prototype property without invoking its getter. An @@ -1281,6 +1369,36 @@ unsafe fn declared_prototype_symbol_holder( None } +/// #10481: the accessor an ordinary `[[Set]]` of `sym` on `obj` must run when +/// `obj` has no own property under it — the nearest inherited holder along +/// the chains the getter reads (the recorded `[[Prototype]]` chain, the +/// synthetic class-id prototype chain, the declared class prototypes), as its +/// `(get, set)` bits. `None` when that holder is a data property or nothing on +/// those chains carries `sym`. Nothing is invoked. +pub(crate) unsafe fn inherited_symbol_accessor(obj: f64, sym: f64) -> Option<(u64, u64)> { + match inherited_symbol_slot(obj, sym)? { + OwnSymbolSlot::Accessor { get, set } => Some((get, set)), + OwnSymbolSlot::Data(_) => None, + } +} + +/// The nearest inherited holder of `sym` for an ordinary object `obj`, in the +/// order the getter consults these chains, without invoking it. +unsafe fn inherited_symbol_slot(obj: f64, sym: f64) -> Option { + if let Some(slot) = explicit_prototype_symbol_slot(obj, sym) { + return Some(slot); + } + let ptr = object_header_ptr_from_value_bits(obj.to_bits())?; + let class_id = crate::object::js_object_get_class_id(ptr as *const _); + if class_id == 0 { + return None; + } + if let Some(slot) = crate::object::proto_chain_symbol_slot(class_id, sym) { + return Some(slot); + } + own_symbol_slot(declared_prototype_symbol_holder(obj, sym, class_id)?, sym) +} + /// Presence of the declared-prototype properties handled above, including /// accessors and data properties whose value is undefined. pub(crate) unsafe fn has_declared_prototype_symbol_property(receiver: f64, sym: f64) -> bool { diff --git a/crates/perry-runtime/src/symbol/inherited_accessor_tests.rs b/crates/perry-runtime/src/symbol/inherited_accessor_tests.rs new file mode 100644 index 0000000000..94b504233f --- /dev/null +++ b/crates/perry-runtime/src/symbol/inherited_accessor_tests.rs @@ -0,0 +1,211 @@ +//! #10481: a Symbol-keyed accessor found on a PROTOTYPE runs with the original +//! receiver as `this`, for `[[Get]]` and `[[Set]]` alike. + +use super::*; +use crate::closure::{js_closure_alloc, js_register_closure_arity, ClosureHeader}; +use std::cell::Cell; + +thread_local! { + static SETTER_CALL: Cell> = const { Cell::new(None) }; +} + +/// Getter body: answers the `this` it was invoked with. +extern "C" fn this_getter(_closure: *const ClosureHeader) -> f64 { + crate::object::js_implicit_this_get() +} + +/// Setter body: records `(this, value)`. +extern "C" fn recording_setter(_closure: *const ClosureHeader, value: f64) -> f64 { + let this = crate::object::js_implicit_this_get(); + SETTER_CALL.with(|c| c.set(Some((this.to_bits(), value.to_bits())))); + f64::from_bits(TAG_UNDEFINED) +} + +unsafe fn closure_bits(f: *const u8, arity: u32) -> u64 { + js_register_closure_arity(f, arity); + crate::value::js_nanbox_pointer(js_closure_alloc(f, 0) as i64).to_bits() +} + +unsafe fn plain_object() -> f64 { + crate::value::js_nanbox_pointer(crate::object::js_object_alloc(0, 0) as i64) +} + +/// `proto` carrying a `[sym]` accessor, `child = Object.create(proto)` and +/// `grandchild = Object.create(child)`. +unsafe fn fixture() -> (f64, f64, f64, f64) { + let sym = super::constructors::js_symbol_new_empty(); + let proto = plain_object(); + set_symbol_accessor_property( + proto, + sym, + closure_bits(this_getter as *const u8, 0), + closure_bits(recording_setter as *const u8, 1), + ); + let child = crate::object::js_object_create(proto); + let grandchild = crate::object::js_object_create(child); + (sym, proto, child, grandchild) +} + +#[test] +fn inherited_symbol_getter_receives_the_original_receiver() { + let _global = crate::gc::global_side_table_test_lock(); + unsafe { + crate::gc::gc_suppress(); + let (sym, proto, child, grandchild) = fixture(); + let own = js_object_get_symbol_property(proto, sym); + let one_up = js_object_get_symbol_property(child, sym); + let two_up = js_object_get_symbol_property(grandchild, sym); + let other = plain_object(); + let reflected = js_object_get_symbol_property_with_receiver(grandchild, sym, other); + let tag = inherited_symbol_property(child, sym); + crate::gc::gc_unsuppress(); + + assert_eq!( + own.to_bits(), + proto.to_bits(), + "an own accessor read on the holder itself sees the holder as `this`" + ); + assert_eq!( + one_up.to_bits(), + child.to_bits(), + "a one-level-inherited getter must see the ORIGINAL receiver, not the prototype that holds it" + ); + assert_eq!( + two_up.to_bits(), + grandchild.to_bits(), + "a two-level-inherited getter must still see the original receiver" + ); + assert_eq!( + reflected.to_bits(), + other.to_bits(), + "an explicit receiver (Reflect.get-shaped call) must reach the getter unchanged" + ); + assert_eq!( + tag.unwrap().to_bits(), + child.to_bits(), + "inherited_symbol_property (the Symbol.toStringTag walk) runs the getter with `this === obj`" + ); + } +} + +#[test] +fn inherited_symbol_setter_receives_the_receiver_and_the_value() { + let _global = crate::gc::global_side_table_test_lock(); + unsafe { + crate::gc::gc_suppress(); + let (sym, proto, child, grandchild) = fixture(); + let value = 42.0_f64; + + SETTER_CALL.with(|c| c.set(None)); + let ret = js_object_set_symbol_property(child, sym, value); + let recorded_child = SETTER_CALL.with(|c| c.get()); + + SETTER_CALL.with(|c| c.set(None)); + let value2 = 43.0_f64; + js_object_set_symbol_property(grandchild, sym, value2); + let recorded_grandchild = SETTER_CALL.with(|c| c.get()); + + SETTER_CALL.with(|c| c.set(None)); + let value3 = 44.0_f64; + js_object_set_symbol_property(proto, sym, value3); + let recorded_own = SETTER_CALL.with(|c| c.get()); + crate::gc::gc_unsuppress(); + + assert_eq!( + ret.to_bits(), + value.to_bits(), + "the setter's return value is the assigned value" + ); + assert_eq!( + recorded_child, + Some((child.to_bits(), value.to_bits())), + "a one-level-inherited setter must run with the write's receiver, not the holder" + ); + assert_eq!( + recorded_grandchild, + Some((grandchild.to_bits(), value2.to_bits())), + "a two-level-inherited setter must still run with the original receiver" + ); + assert_eq!( + recorded_own, + Some((proto.to_bits(), value3.to_bits())), + "an own accessor write runs with the object written to as `this`" + ); + } +} + +#[test] +fn own_data_property_shadows_an_inherited_accessor_for_read_and_write() { + let _global = crate::gc::global_side_table_test_lock(); + unsafe { + crate::gc::gc_suppress(); + let (sym, _proto, child, _grandchild) = fixture(); + let data_value = 99.0_f64; + define_symbol_data_property(child, sym, data_value); + + SETTER_CALL.with(|c| c.set(None)); + let read = js_object_get_symbol_property(child, sym); + let read_did_not_invoke_getter = SETTER_CALL.with(|c| c.get()).is_none(); + + let new_value = 100.0_f64; + let written = js_object_set_symbol_property(child, sym, new_value); + let write_invoked_setter = SETTER_CALL.with(|c| c.get()).is_some(); + let read_after = js_object_get_symbol_property(child, sym); + crate::gc::gc_unsuppress(); + + assert_eq!( + read.to_bits(), + data_value.to_bits(), + "a nearer own DATA property must shadow the inherited accessor on read" + ); + assert!( + read_did_not_invoke_getter, + "reading a shadowing own data property must not run the inherited getter" + ); + assert_eq!( + written.to_bits(), + new_value.to_bits(), + "writing a shadowed key returns the assigned value" + ); + assert!( + !write_invoked_setter, + "writing a nearer own data property must not run the inherited setter" + ); + assert_eq!( + read_after.to_bits(), + new_value.to_bits(), + "the own data property must be updated in place, not routed to the inherited accessor" + ); + } +} + +#[test] +fn symbol_may_have_accessor_is_false_until_an_accessor_is_installed() { + let _global = crate::gc::global_side_table_test_lock(); + unsafe { + crate::gc::gc_suppress(); + let sym = super::constructors::js_symbol_new_empty(); + let sym_key = sym_key_from_f64(sym); + let before = super::accessors::symbol_may_have_accessor(sym_key); + let bits_before = super::accessors::test_symbol_accessor_id_bits_set(); + + let holder = plain_object(); + set_symbol_accessor_property(holder, sym, closure_bits(this_getter as *const u8, 0), 0); + let after = super::accessors::symbol_may_have_accessor(sym_key); + let bits_after = super::accessors::test_symbol_accessor_id_bits_set(); + crate::gc::gc_unsuppress(); + + assert!( + !before, + "a freshly minted symbol must not read as accessor-bearing before any accessor exists" + ); + assert!( + after, + "installing an accessor under a symbol must flip its filter bit" + ); + assert!( + bits_after >= bits_before, + "the filter's population count must never decrease" + ); + } +} diff --git a/crates/perry-runtime/src/symbol/properties.rs b/crates/perry-runtime/src/symbol/properties.rs index ddcbcf4f32..27934f2c2d 100644 --- a/crates/perry-runtime/src/symbol/properties.rs +++ b/crates/perry-runtime/src/symbol/properties.rs @@ -323,14 +323,8 @@ fn next_request_meta_sym_key() -> usize { unsafe fn set_symbol_property(obj_f64: f64, sym_f64: f64, value_f64: f64) -> f64 { if let Some(acc) = accessors::symbol_accessor_property(obj_f64, sym_f64) { - if acc.set != 0 { - let closure = - (acc.set & crate::value::POINTER_MASK) as *const crate::closure::ClosureHeader; - if !closure.is_null() { - crate::closure::js_closure_call1(closure, value_f64); - } - } - return value_f64; + // #10481: the setter runs with the object written to as `this`. + return accessors::invoke_symbol_accessor_setter(acc.set, obj_f64, value_f64); } let obj_key = obj_key_from_f64(obj_f64); let sym_key = sym_key_from_f64(sym_f64); @@ -436,6 +430,22 @@ unsafe fn set_symbol_property(obj_f64: f64, sym_f64: f64, value_f64: f64) -> f64 { return value_f64; } + // #10481: an INHERITED symbol accessor — installed by + // `Object.defineProperty(Fn.prototype, sym, …)`, an object + // literal `set [sym](v)` reached through `Object.create`, + // or on a declared class prototype — intercepts the write: + // its setter runs with the receiver, and a getter-only + // accessor leaves the receiver untouched. The write used + // to shadow it with a new own data property instead. + if accessors::symbol_may_have_accessor(sym_key) { + if let Some((_, set_bits)) = + super::get::inherited_symbol_accessor(obj_f64, sym_f64) + { + return accessors::invoke_symbol_accessor_setter( + set_bits, obj_f64, value_f64, + ); + } + } } } } diff --git a/test-files/test_gap_10481_inherited_symbol_getter_receiver.ts b/test-files/test_gap_10481_inherited_symbol_getter_receiver.ts new file mode 100644 index 0000000000..2660910242 --- /dev/null +++ b/test-files/test_gap_10481_inherited_symbol_getter_receiver.ts @@ -0,0 +1,173 @@ +// #10481: an INHERITED Symbol-keyed accessor must run with the ORIGINAL +// receiver as `this` ([[Get]](P, Receiver) / [[Set]](P, V, Receiver)), for +// every read form, at any prototype depth, whatever built the prototype. +// fastify 5's `lib/reply.js` `[kRouteContext]` getter crashed every request +// with `this === undefined`. + +function show(label: string, f: () => unknown): void { + try { + const v = f(); + console.log(label, typeof v === "symbol" ? String(v) : JSON.stringify(v)); + } catch (e: any) { + console.log(label, "THREW", e instanceof TypeError ? "TypeError" : String(e), e.message ?? ""); + } +} + +// --------------------------------------------------------------------------- +// 1. fastify lib/reply.js shape: function constructor + defineProperties. +// --------------------------------------------------------------------------- +const kRouteContext = Symbol("kRouteContext"); +function Reply(this: any, request: any) { + this.request = request; +} +Object.defineProperties(Reply.prototype, { + [kRouteContext]: { + get() { + return this.request[kRouteContext]; + }, + }, + routeOptions: { + get() { + return this.request[kRouteContext]; + }, + }, +}); +const reply: any = new (Reply as any)({ [kRouteContext]: "ctx" }); +show("1a symbol-keyed prototype getter (fn ctor):", () => reply[kRouteContext]); +show("1b string-keyed prototype getter (control):", () => reply.routeOptions); + +const kAsAny: any = kRouteContext; +show("1c symbol getter through an any-typed key var:", () => reply[kAsAny]); + +show("1d optional chaining on the same getter:", () => reply?.[kRouteContext]); + +show("1e destructured symbol-keyed read:", () => { + const { [kRouteContext]: v } = reply; + return v; +}); + +console.log("1f kRouteContext in reply:", kRouteContext in reply); + +// --------------------------------------------------------------------------- +// 2. object-literal `get [sym]()` reached through Object.create, one and two +// levels deep. +// --------------------------------------------------------------------------- +const k = Symbol("k"); +const proto: any = { + get [k]() { + return this === undefined ? "this===undefined" : this.v; + }, +}; +const child = Object.create(proto); +child.v = 7; +const grandchild = Object.create(child); +grandchild.v = 9; + +show("2a inherited literal getter, one level (Object.create):", () => child[k]); +show("2b inherited literal getter, two levels (Object.create):", () => grandchild[k]); + +// --------------------------------------------------------------------------- +// 3. own symbol getter (control) — must be unaffected. +// --------------------------------------------------------------------------- +const own: any = { v: 8 }; +Object.defineProperty(own, k, { + get() { + return this.v; + }, +}); +show("3a own symbol getter (control):", () => own[k]); + +// --------------------------------------------------------------------------- +// 4. Reflect.get — with and without an explicit receiver. +// --------------------------------------------------------------------------- +show("4a Reflect.get(child, k) (default receiver = child):", () => Reflect.get(child, k)); +const other: any = { v: 100 }; +show("4b Reflect.get(child, k, other) (explicit receiver):", () => Reflect.get(child, k, other)); + +// --------------------------------------------------------------------------- +// 5. a nearer own data property shadows an inherited accessor. +// --------------------------------------------------------------------------- +const shadowed: any = Object.create(proto); +shadowed.v = 1; +// `proto`'s `[k]` is getter-only, so a plain `shadowed[k] = ...` would walk up +// to it and throw (no setter) in strict mode; Object.defineProperty creates +// the OWN data property directly, without going through [[Set]]. +Object.defineProperty(shadowed, k, { value: "own-data", writable: true, enumerable: true, configurable: true }); +show("5a own data property shadows inherited accessor (read):", () => shadowed[k]); + +// --------------------------------------------------------------------------- +// 6. write forms — an inherited setter must run with the receiver (each +// instance keeps its own state), and Reflect.set must reach it too. +// --------------------------------------------------------------------------- +const wproto: any = { + _store: new Map(), + get [k]() { + return this._store.get(this); + }, + set [k](v: unknown) { + this._store.set(this, v); + }, +}; +const w1: any = Object.create(wproto); +const w2: any = Object.create(wproto); +w1[k] = "w1-value"; +w2[k] = "w2-value"; +show("6a inherited setter keeps per-receiver state (w1):", () => w1[k]); +show("6b inherited setter keeps per-receiver state (w2):", () => w2[k]); +console.log( + "6c write did not create a shadowing own property:", + Object.prototype.hasOwnProperty.call(w1, k) === false && + Object.prototype.hasOwnProperty.call(w2, k) === false, +); + +Reflect.set(w1, k, "w1-via-reflect"); +show("6d Reflect.set through the inherited setter:", () => w1[k]); + +// --------------------------------------------------------------------------- +// 7. declared class prototype accessor, inherited by a subclass instance +// (a field the subclass does NOT itself declare, so the subclass's own +// field initializer can't shadow anything — isolates receiver identity +// from unrelated field-initialization-order concerns). +// --------------------------------------------------------------------------- +const kTag = Symbol("kTag"); +class Base {} +Object.defineProperty(Base.prototype, kTag, { + get() { + return (this as any).ownVal; + }, +}); +class Sub extends Base { + ownVal = "sub-own"; +} +const sub = new Sub(); +show("7a inherited getter on a declared class prototype:", () => (sub as any)[kTag]); + +// --------------------------------------------------------------------------- +// 8. Symbol.toStringTag through Object.prototype.toString, inherited. +// --------------------------------------------------------------------------- +function Widget(this: any) {} +Object.defineProperty(Widget.prototype, Symbol.toStringTag, { + get() { + return "MyWidget"; + }, +}); +const widget = new (Widget as any)(); +console.log("8a inherited Symbol.toStringTag getter:", Object.prototype.toString.call(widget)); + +// --------------------------------------------------------------------------- +// 9. two-level inheritance via Object.setPrototypeOf on function prototypes. +// --------------------------------------------------------------------------- +function R(this: any, request: any) { + this.request = request; +} +Object.defineProperty(R.prototype, kRouteContext, { + get() { + return this.request[kRouteContext]; + }, +}); +function Two(this: any, request: any) { + this.request = request; +} +Object.setPrototypeOf(Two.prototype, R.prototype); +const two: any = new (Two as any)({ [kRouteContext]: "two-ctx" }); +show("9a two-level inherited getter via setPrototypeOf:", () => two[kRouteContext]); From 3b46e2c3c00b34465da9ddbe7c0c57b7731baa5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 07:03:13 +0000 Subject: [PATCH 2/3] changelog: fragment for #10597 --- .../10597-inherited-symbol-getter-receiver.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 changelog.d/10597-inherited-symbol-getter-receiver.md diff --git a/changelog.d/10597-inherited-symbol-getter-receiver.md b/changelog.d/10597-inherited-symbol-getter-receiver.md new file mode 100644 index 0000000000..8161f2bfc3 --- /dev/null +++ b/changelog.d/10597-inherited-symbol-getter-receiver.md @@ -0,0 +1,15 @@ +### Fixed + +- **An inherited Symbol-keyed accessor now runs with the original receiver, not `undefined`.** + `obj[sym]` where the getter/setter lives on a prototype (`Object.defineProperty(Fn.prototype, sym, + ...)`, an object-literal `get [sym]()` reached through `Object.create`, or a declared class + prototype) used to invoke the accessor with no receiver at all, so it observed whatever `this` + happened to be ambient — `undefined` at module top level. fastify 5.10.0's + `Reply.prototype[kRouteContext]` getter crashed every HTTP request with `TypeError: Cannot read + properties of undefined (reading 'request')`. `[[Get]]`/`[[Set]]` now thread the read/write's + actual receiver through every prototype-chain walk (`crates/perry-runtime/src/symbol/get.rs`, + `object/class_registry/prototype_objects.rs`); `Reflect.get`/`Reflect.set` for a Symbol key reach + the receiver-aware entry points directly. An inherited *setter* is now consulted too — + `obj[sym] = v` used to silently shadow it with a new own data property instead of running it — + gated by a symbol-id-keyed accessor filter (`symbol_may_have_accessor`) so the common no-accessor + write path stays cheap. From fbe28192add5e821901afe1495729818b072548f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 07:26:32 +0000 Subject: [PATCH 3/3] fix(runtime): thread the receiver through two more inherited-symbol paths Two CodeRabbit-flagged gaps in the #10481 receiver fix: 1. get.rs: the parent-closure fallback (a class extending a function value, e.g. class Svc extends Context.Tag(id)<...>() {}) called the no-receiver js_object_get_symbol_property entry point, so Reflect.get(Svc, sym, other) saw the parent closure as this instead of other. 2. properties.rs: an inherited symbol setter was never reached on a non-extensible receiver - the OBJ_FLAG_NO_EXTEND check returned early before the inherited-accessor walk ran. [[Set]] through an inherited accessor never creates a new own property, so non-extensibility must not block it; moved the inherited-setter check ahead of that gate. --- crates/perry-runtime/src/symbol/get.rs | 6 ++- crates/perry-runtime/src/symbol/properties.rs | 46 +++++++++++++------ 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/crates/perry-runtime/src/symbol/get.rs b/crates/perry-runtime/src/symbol/get.rs index 4b9341255a..30bd25b0e6 100644 --- a/crates/perry-runtime/src/symbol/get.rs +++ b/crates/perry-runtime/src/symbol/get.rs @@ -722,7 +722,11 @@ pub(crate) unsafe fn js_object_get_symbol_property_with_receiver( if let Some(closure_ptr) = crate::object::parent_closure_in_chain(class_id) { let closure_f64 = f64::from_bits(crate::value::js_nanbox_pointer(closure_ptr as i64).to_bits()); - let v = js_object_get_symbol_property(closure_f64, sym_f64); + // #10481: preserve the caller's receiver here too — without it, an + // accessor reached through the parent closure's own symbol walk + // would see the closure as `this` instead of the original + // receiver (e.g. `Reflect.get(Child, sym, other)`). + let v = js_object_get_symbol_property_with_receiver(closure_f64, sym_f64, receiver_f64); if v.to_bits() != TAG_UNDEFINED { return v; } diff --git a/crates/perry-runtime/src/symbol/properties.rs b/crates/perry-runtime/src/symbol/properties.rs index 27934f2c2d..c55431ae52 100644 --- a/crates/perry-runtime/src/symbol/properties.rs +++ b/crates/perry-runtime/src/symbol/properties.rs @@ -385,6 +385,33 @@ unsafe fn set_symbol_property(obj_f64: f64, sym_f64: f64, value_f64: f64) -> f64 crate::array::note_array_proto_iterator_write(obj_key, sym_key); crate::object::map_set_subclass::note_iterator_symbol_write(obj_key, sym_key); let has_own_data = object_symbol_data_property_exists(obj_key, sym_key); + // #10481: an INHERITED symbol accessor's setter runs even on a + // non-extensible receiver — [[Set]] through an inherited accessor never + // creates a new own property, so OBJ_FLAG_NO_EXTEND (checked below) must + // not block it. Checked first, ahead of the extensibility gate, using + // the same pointer-object condition the class-setter/accessor fallback + // below uses. + if !has_own_data && !native_async_resource { + let bits = obj_f64.to_bits(); + if (bits >> 48) != 0x7FFE { + let jsval = crate::value::JSValue::from_bits(bits); + if jsval.is_pointer() { + let ptr = jsval.as_pointer::(); + if !ptr.is_null() + && crate::object::is_valid_obj_ptr(ptr as *const u8) + && accessors::symbol_may_have_accessor(sym_key) + { + if let Some((_, set_bits)) = + super::get::inherited_symbol_accessor(obj_f64, sym_f64) + { + return accessors::invoke_symbol_accessor_setter( + set_bits, obj_f64, value_f64, + ); + } + } + } + } + } // Frozen / sealed / non-extensible receivers reject symbol-keyed writes // like string-keyed ones: an existing prop is non-writable when frozen // (or its per-symbol attrs say so), a new prop is forbidden when @@ -430,22 +457,11 @@ unsafe fn set_symbol_property(obj_f64: f64, sym_f64: f64, value_f64: f64) -> f64 { return value_f64; } - // #10481: an INHERITED symbol accessor — installed by + // #10481: an inherited accessor (installed by // `Object.defineProperty(Fn.prototype, sym, …)`, an object - // literal `set [sym](v)` reached through `Object.create`, - // or on a declared class prototype — intercepts the write: - // its setter runs with the receiver, and a getter-only - // accessor leaves the receiver untouched. The write used - // to shadow it with a new own data property instead. - if accessors::symbol_may_have_accessor(sym_key) { - if let Some((_, set_bits)) = - super::get::inherited_symbol_accessor(obj_f64, sym_f64) - { - return accessors::invoke_symbol_accessor_setter( - set_bits, obj_f64, value_f64, - ); - } - } + // literal `set [sym](v)` reached through `Object.create`, or a + // declared class prototype) is checked ABOVE, before the + // extensibility gate, so it is never reached from here. } } }