From ec2d22cc1514ae1bd12c056d1ed4ed54db32f3b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 21:34:37 +0000 Subject: [PATCH 01/11] fix(runtime): a builtin parent id never gets a class function object Object.getPrototypeOf of a class, the static `super` parent value (template_dynamic_parent_value) and a static `super[k] = v` (js_super_put_value_set) stepped to the parent id and minted a class function object for it even when it is a builtin id (`class E extends Error`), tripping class_value_mint's debug_assert in debug builds and growing the class-value directory. They now step only to registered classes (is_class_id_registered, the rule of class_prototype_addr): a builtin constructor is the class's dynamic parent value, and a class without one is a root. a_builtin_parent_never_gets_a_class_function_object covers all three. --- changelog.d/class-builtin-parent-no-class-object.md | 4 ++++ .../src/object/class_registry/parent_static.rs | 4 +++- crates/perry-runtime/src/object/class_value.rs | 13 +++++++++++++ .../src/object/object_ops/prototype.rs | 8 +++++++- crates/perry-runtime/src/proxy.rs | 13 ++++++++----- 5 files changed, 35 insertions(+), 7 deletions(-) create mode 100644 changelog.d/class-builtin-parent-no-class-object.md diff --git a/changelog.d/class-builtin-parent-no-class-object.md b/changelog.d/class-builtin-parent-no-class-object.md new file mode 100644 index 0000000000..9e4412c059 --- /dev/null +++ b/changelog.d/class-builtin-parent-no-class-object.md @@ -0,0 +1,4 @@ +`Object.getPrototypeOf` of a class, a static `super` parent lookup and a +static `super[k] = v` no longer create a class function object for a builtin +parent id (`class E extends Error`): the builtin constructor is the class's +dynamic parent value, and a class without one is a root. diff --git a/crates/perry-runtime/src/object/class_registry/parent_static.rs b/crates/perry-runtime/src/object/class_registry/parent_static.rs index c9c75951d1..1d6bab592b 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -525,7 +525,9 @@ pub(crate) fn template_dynamic_parent_value(class_id: u32) -> f64 { // registered-constructor flat dispatch, which fills user args and // snapshot caps by the signature split. if let Some(parent_cid) = crate::object::get_parent_class_id(class_id) { - if parent_cid != 0 { + // Only a compiled parent class has a class function object; a + // builtin parent id (`extends Error`) never gets one. + if parent_cid != 0 && crate::object::is_class_id_registered(parent_cid) { return crate::object::class_value::class_value(parent_cid); } } diff --git a/crates/perry-runtime/src/object/class_value.rs b/crates/perry-runtime/src/object/class_value.rs index 1d6909fed8..dac5b9a1d4 100644 --- a/crates/perry-runtime/src/object/class_value.rs +++ b/crates/perry-runtime/src/object/class_value.rs @@ -887,6 +887,19 @@ mod tests { crate::symbol::class_static_symbol_keys_for_class(crate::error::CLASS_ID_ERROR) .is_empty() ); + // Object.getPrototypeOf(C), the static `super` parent value and a + // static `super[k] = v` all step to the parent id; none may mint. + let _ = crate::object::js_object_get_prototype_of(recv); + let _ = crate::object::class_registry::parent_static::template_dynamic_parent_value(cid); + let _ = crate::proxy::js_super_put_value_set( + crate::error::CLASS_ID_ERROR, + crate::value::js_nanbox_string( + crate::string::js_string_from_bytes(b"zz".as_ptr(), 2) as i64 + ), + 1.0, + recv, + 0, + ); assert!( class_value_cached(crate::error::CLASS_ID_ERROR).is_none(), "the builtin Error id must not get a class function object" diff --git a/crates/perry-runtime/src/object/object_ops/prototype.rs b/crates/perry-runtime/src/object/object_ops/prototype.rs index baf5a2f05c..c35b41d68a 100644 --- a/crates/perry-runtime/src/object/object_ops/prototype.rs +++ b/crates/perry-runtime/src/object/object_ops/prototype.rs @@ -441,7 +441,13 @@ fn get_prototype_of_resolved(obj_value: f64) -> f64 { // Treat it as a root: a native-module namespace's [[Prototype]] is // %Object.prototype%, so the synthetic class whose proto was that // namespace inherits Object.prototype too. - if parent_id != 0 && parent_id != super::super::native_module::NATIVE_MODULE_CLASS_ID { + // A builtin parent id (`extends Error`) is not a compiled class + // and never gets a class function object: its constructor is the + // dynamic parent value above, and without one this is a root. + if parent_id != 0 + && parent_id != super::super::native_module::NATIVE_MODULE_CLASS_ID + && crate::object::is_class_id_registered(parent_id) + { return crate::object::class_value::class_value(parent_id); } } diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index 8ec725d66d..34a4e5d8cb 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -2470,11 +2470,14 @@ pub extern "C" fn js_super_put_value_set( // the value captured at class-definition time. The previous instance-only // path looked at `Parent.prototype` and made valid static writes fail. if let Some(child_id) = crate::object::class_ref_id(receiver) { - let target = if parent_class_id != 0 { - crate::object::class_value::class_value(parent_class_id) - } else { - crate::object::js_get_dynamic_parent_value(child_id) - }; + // A builtin parent id (`extends Error`) never gets a class function + // object; its constructor is the class's dynamic parent value. + let target = + if parent_class_id != 0 && crate::object::is_class_id_registered(parent_class_id) { + crate::object::class_value::class_value(parent_class_id) + } else { + crate::object::js_get_dynamic_parent_value(child_id) + }; let tv = crate::value::JSValue::from_bits(target.to_bits()); if !tv.is_undefined() && !tv.is_null() { return js_put_value_set(target, key, value, receiver, strict); From 2d66968cc18c978a52ad703984d86edee2023380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 22:10:45 +0000 Subject: [PATCH 02/11] feat(runtime): class static attributes live with their keys (3f, part 1) CLASS_STATIC_DEFINED_ATTRS is gone. A class static's attributes are the key attributes of the class function object's own-property object, as for any ordinary object (the intrinsic name/length included): the class_static_{set,clear}_defined_attrs / class_static_defined_attrs API now writes and reads those keys, and never mints a function object to read. A delete removes the attributes with the key, so a deleted static that is assigned again is an ordinary writable, enumerable property (the table kept the deleted key's attributes). The class paths perform [[Set]] themselves (they check `writable`), so the function object's own data properties are stored with a value-only define (bag_define_value) that keeps the key's attributes. Object.freeze / Object.seal / isFrozen / isSealed of a class function object act on those keys too (class_static_restrict_all / class_static_integrity); freezing a class used to leave its statics reported writable and configurable. Test: test_gap_class_static_attrs_delete.ts. --- changelog.d/class-static-attrs-on-keys.md | 6 ++ crates/perry-runtime/src/closure/props.rs | 15 ++++ .../src/object/class_registry/state.rs | 74 ++++++++++++------- .../perry-runtime/src/object/class_value.rs | 48 +++++++++++- crates/perry-runtime/src/object/mod.rs | 11 --- .../src/object/object_ops_frozen.rs | 19 +++++ scripts/gc_runtime_root_holders.json | 4 - .../test_gap_class_static_attrs_delete.ts | 34 +++++++++ 8 files changed, 167 insertions(+), 44 deletions(-) create mode 100644 changelog.d/class-static-attrs-on-keys.md create mode 100644 test-files/test_gap_class_static_attrs_delete.ts diff --git a/changelog.d/class-static-attrs-on-keys.md b/changelog.d/class-static-attrs-on-keys.md new file mode 100644 index 0000000000..65b0fea11c --- /dev/null +++ b/changelog.d/class-static-attrs-on-keys.md @@ -0,0 +1,6 @@ +A class static's attributes (`writable`/`enumerable`/`configurable`, set by +`Object.defineProperty`, `Object.freeze` or a class's intrinsic `name` and +`length`) are now the key attributes of the class function object's own +properties, as for any ordinary object, instead of a separate per-class table. +Deleting such a static and assigning it again yields an ordinary writable, +enumerable property (the old table kept the deleted key's attributes). diff --git a/crates/perry-runtime/src/closure/props.rs b/crates/perry-runtime/src/closure/props.rs index 3840a4b096..f3c486f3fc 100644 --- a/crates/perry-runtime/src/closure/props.rs +++ b/crates/perry-runtime/src/closure/props.rs @@ -122,6 +122,21 @@ pub(crate) unsafe fn bag_set(ptr: usize, key: &str, value: f64) { object_own_set(bag, key, value); } +/// [[DefineOwnProperty]] of own data property `key` with just a value: the +/// value is stored and the key keeps (or, when new, gets default) attributes. +/// Unlike [`bag_set`] it ignores the key's `writable` attribute; a caller +/// that is performing a [[Set]] has checked it (a class function object's +/// statics, whose attributes live with these keys). +/// +/// # Safety +/// `ptr` is a proven, live closure cell. +pub(crate) unsafe fn bag_define_value(ptr: usize, key: &str, value: f64) { + let _no_move = crate::gc::GcSuppressScope::new(); + let bag = bag_ensure(ptr); + let key = crate::string::js_string_from_bytes(key.as_ptr(), key.len() as u32); + crate::object::object_ops::define_property_force_store_value(bag, key, value); +} + /// Remove the function's own data property `key`; true when it existed. /// /// # Safety diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index da95bbd52f..8e5d1bac47 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -202,10 +202,11 @@ pub(crate) fn class_own_dynamic_prop_names(class_id: u32) -> Vec { .collect() } -/// #7190: record a `defineProperty`-installed static key's attributes. Called -/// only from the define path; `static x = …` never touches it, so a declared -/// field keeps its CreateDataPropertyOrThrow `(writable, enumerable) = (true, -/// true)` reporting. +/// #7190: set the attributes of class `class_id`'s own static data property +/// `name`. They are the key attributes of the class function object's +/// own-property object, as for any ordinary object: a declared `static x = …` +/// field never sets any, so it keeps CreateDataPropertyOrThrow's +/// `(true, true, true)`, and a delete removes them with the key. pub(crate) fn class_static_set_defined_attrs( class_id: u32, name: &str, @@ -213,37 +214,56 @@ pub(crate) fn class_static_set_defined_attrs( enumerable: bool, configurable: bool, ) { - crate::object::CLASS_STATIC_DEFINED_ATTRS.with(|m| { - m.borrow_mut() - .entry(class_id) - .or_default() - .insert(name.to_string(), (writable, enumerable, configurable)); - }); + { + let _no_collect = crate::gc::GcSuppressScope::new(); + let ptr = crate::object::class_value::class_value_ptr(class_id) as usize; + // SAFETY: this agent's live class function object; no collection in + // this scope. + let bag = unsafe { crate::closure::props::bag_ensure(ptr) }; + crate::object::set_builtin_property_attrs( + bag as usize, + name.to_string(), + crate::object::PropertyAttrs::new(writable, enumerable, configurable), + ); + } class_static_alias_sync(class_id, name); } -/// Forget the recorded attributes of static `name` (it becomes an ordinary -/// writable, enumerable, configurable data property again) and re-sync its -/// compiled alias. A static FIELD definition does this: DefineField creates -/// the property with CreateDataPropertyOrThrow, replacing e.g. the class's -/// own intrinsic `name`. +/// Static `name` becomes an ordinary writable, enumerable, configurable data +/// property again, and its compiled alias is re-synced. A static FIELD +/// definition does this: DefineField creates the property with +/// CreateDataPropertyOrThrow, replacing e.g. the class's own intrinsic `name`. pub(crate) fn class_static_clear_defined_attrs(class_id: u32, name: &str) { - let removed = crate::object::CLASS_STATIC_DEFINED_ATTRS.with(|m| { - m.borrow_mut() - .get_mut(&class_id) - .and_then(|k| k.remove(name)) - .is_some() - }); - if removed { - class_static_alias_sync(class_id, name); + let Some(ptr) = crate::object::class_value::class_value_if_minted(class_id) else { + return; + }; + // SAFETY: this agent's live class function object. + let bag = unsafe { crate::closure::props::bag_of(ptr as usize) }; + if bag.is_null() { + return; } + crate::object::clear_property_attrs(bag as usize, name); + class_static_alias_sync(class_id, name); } -/// `(writable, enumerable)` if this static key was installed by -/// `Object.defineProperty`; `None` for a declared `static x = …` field. +/// `(writable, enumerable, configurable)` of class `class_id`'s own static +/// DATA property `name`; `None` when it owns no such data property. Reads +/// the key of the function object's own-property object and never mints the +/// function object (one never created owns no properties). pub(crate) fn class_static_defined_attrs(class_id: u32, name: &str) -> Option<(bool, bool, bool)> { - crate::object::CLASS_STATIC_DEFINED_ATTRS - .with(|m| m.borrow().get(&class_id).and_then(|k| k.get(name)).copied()) + let ptr = crate::object::class_value::class_value_if_minted(class_id)? as usize; + // SAFETY: this agent's live class function object. + unsafe { + if crate::object::is_internal_runtime_key(name) + || crate::closure::props::bag_get(ptr, name.as_bytes()).is_none() + { + return None; + } + let bag = crate::closure::props::bag_of(ptr); + let attrs = crate::object::get_property_attrs(bag as usize, name) + .unwrap_or(crate::object::PropertyAttrs::new(true, true, true)); + Some((attrs.writable(), attrs.enumerable(), attrs.configurable())) + } } pub(crate) fn class_static_key_is_non_enumerable(class_id: u32, name: &str) -> bool { diff --git a/crates/perry-runtime/src/object/class_value.rs b/crates/perry-runtime/src/object/class_value.rs index dac5b9a1d4..4968502935 100644 --- a/crates/perry-runtime/src/object/class_value.rs +++ b/crates/perry-runtime/src/object/class_value.rs @@ -774,7 +774,9 @@ pub(crate) fn class_static_get(class_id: u32, name: &str) -> Option { } } -/// Define/overwrite class `class_id`'s own static data property `name`. +/// Define/overwrite class `class_id`'s own static data property `name`: the +/// value only, the key keeps its attributes. Callers performing a [[Set]] +/// have checked `writable` (the attributes live with the key). pub(crate) fn class_static_set(class_id: u32, name: &str, value: f64) { let ptr = class_value_ptr(class_id) as usize; // SAFETY: as above; the bag writers run under a GcSuppressScope. @@ -782,7 +784,7 @@ pub(crate) fn class_static_set(class_id: u32, name: &str, value: f64) { if is_internal_static_key(name) { crate::closure::props::state_internal_set(ptr, name, value); } else { - crate::closure::props::bag_set(ptr, name, value); + crate::closure::props::bag_define_value(ptr, name, value); } } } @@ -801,6 +803,48 @@ pub(crate) fn class_static_remove(class_id: u32, name: &str) -> bool { } } +/// `Object.freeze` / `Object.seal` of class `class_id`'s function object: +/// every own string-keyed property becomes non-configurable, and with +/// `drop_writable` every data property non-writable. The attributes are +/// those of the own-property object's keys. +pub(crate) fn class_static_restrict_all(class_id: u32, drop_writable: bool) { + for (name, _) in class_static_entries(class_id) { + if let Some((writable, enumerable, _)) = + super::class_registry::class_static_defined_attrs(class_id, &name) + { + super::class_registry::class_static_set_defined_attrs( + class_id, + &name, + writable && !drop_writable, + enumerable, + false, + ); + } + } + for name in class_static_accessor_names(class_id) { + if let Some((_, enumerable, _)) = class_static_own_accessor(class_id, &name) { + class_static_set_accessor_attrs(class_id, &name, enumerable, false); + } + } +} + +/// TestIntegrityLevel over class `class_id`'s own string-keyed properties +/// (the object is already known non-extensible): none configurable, and +/// when `frozen` no data property writable. +pub(crate) fn class_static_integrity(class_id: u32, frozen: bool) -> bool { + for (name, _) in class_static_entries(class_id) { + let (writable, _, configurable) = + super::class_registry::class_static_defined_attrs(class_id, &name) + .unwrap_or((true, true, true)); + if configurable || (frozen && writable) { + return false; + } + } + class_static_accessor_names(class_id).iter().all(|name| { + class_static_own_accessor(class_id, name).is_none_or(|(_, _, configurable)| !configurable) + }) +} + /// Class `class_id`'s own static data properties in own-key order (integer /// keys ascending, then creation order). Internal keys are not properties and /// never appear. diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 30e4a1660c..ecf3978ac8 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -741,17 +741,6 @@ pub(crate) struct ShapeCacheEntry { keys_array: *mut ArrayHeader, } -crate::perry_thread_local! { - /// #7190: `(writable, enumerable)` for static own keys installed by - /// `Object.defineProperty(C, k, desc)`. They live in `CLASS_DYNAMIC_PROPS` - /// next to `static x = …` fields, which are writable AND enumerable by - /// CreateDataPropertyOrThrow — a data descriptor defaults to neither. An - /// ABSENT entry therefore means "declared static field", and keeps the - /// previous `(true, true)` reporting untouched. - pub(crate) static CLASS_STATIC_DEFINED_ATTRS: std::cell::RefCell>> = - std::cell::RefCell::new(std::collections::HashMap::new()); -} - // Storage: `ObjectHotTables::{shape_inline_cache, shape_cache_overflow}`. /// Look up a static shape's keys by shape_id. `ObjectKeys::NONE` on miss. diff --git a/crates/perry-runtime/src/object/object_ops_frozen.rs b/crates/perry-runtime/src/object/object_ops_frozen.rs index 164cb678c9..77a532f62d 100644 --- a/crates/perry-runtime/src/object/object_ops_frozen.rs +++ b/crates/perry-runtime/src/object/object_ops_frozen.rs @@ -213,6 +213,15 @@ pub extern "C" fn js_object_freeze(obj_value: f64) -> f64 { // Closures: own props are `name`/`length` + dynamic props — the // keys_array walk below would read garbage off the ClosureHeader. // Record explicit non-writable/non-configurable attrs. + // A class function object's own properties (and their + // attributes) live in its own-property object. + if let Some(class_id) = crate::object::class_value::class_closure_id(obj as usize) { + crate::object::class_value::class_static_restrict_all(class_id, true); + mark_all_symbol_keys( + obj, /*drop_writable=*/ true, /*drop_configurable=*/ true, + ); + return obj_value; + } if crate::closure::is_closure_ptr(obj as usize) { let owner = obj as usize; for builtin in ["name", "length"] { @@ -321,6 +330,13 @@ pub extern "C" fn js_object_seal(obj_value: f64) -> f64 { } // Closures: seal via the side tables (drop configurable only) — // see the matching arm in `js_object_freeze`. + if let Some(class_id) = crate::object::class_value::class_closure_id(obj as usize) { + crate::object::class_value::class_static_restrict_all(class_id, false); + mark_all_symbol_keys( + obj, /*drop_writable=*/ false, /*drop_configurable=*/ true, + ); + return obj_value; + } if crate::closure::is_closure_ptr(obj as usize) { let owner = obj as usize; for builtin in ["name", "length"] { @@ -433,6 +449,9 @@ unsafe fn object_integrity_level(obj: *mut ObjectHeader, frozen: bool) -> bool { // ClosureHeader. `name`/`length` are non-writable but configurable by // default, so an un-frozen function fails both levels; `js_object_freeze` // / `seal` record explicit attrs that satisfy them. + if let Some(class_id) = crate::object::class_value::class_closure_id(obj as usize) { + return crate::object::class_value::class_static_integrity(class_id, frozen); + } if (*gc).obj_type == crate::gc::GC_TYPE_CLOSURE || crate::closure::is_closure_ptr(obj as usize) { let owner = obj as usize; diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 37a24eb8fe..133d51a6ba 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -3820,10 +3820,6 @@ "file": "crates/perry-runtime/src/object/mod.rs", "name": "CLASS_PROTOTYPE_METHOD_VALUES" }, - { - "file": "crates/perry-runtime/src/object/mod.rs", - "name": "CLASS_STATIC_DEFINED_ATTRS" - }, { "file": "crates/perry-runtime/src/object/mod.rs", "name": "FS_CONSTANTS_CACHE_SLOT" diff --git a/test-files/test_gap_class_static_attrs_delete.ts b/test-files/test_gap_class_static_attrs_delete.ts new file mode 100644 index 0000000000..0e88fa9508 --- /dev/null +++ b/test-files/test_gap_class_static_attrs_delete.ts @@ -0,0 +1,34 @@ +// Class static attributes live with the keys of the class function object. +class C { + static f = 1; + static g() { return 2; } +} +const d = (k: string) => JSON.stringify(Object.getOwnPropertyDescriptor(C, k)); +console.log(d("f"), d("name"), d("length")); +Object.defineProperty(C, "x", { value: 1, writable: false, enumerable: false, configurable: true }); +console.log(d("x"), Object.keys(C).join(",")); +try { (C as any).x = 2; } catch (e) { console.log("threw x"); } +console.log((C as any).x); +delete (C as any).x; +console.log(d("x"), "x" in C); +(C as any).x = 3; +console.log(d("x"), (C as any).x, Object.keys(C).join(",")); +Object.defineProperty(C, "f", { enumerable: false }); +console.log(d("f"), Object.keys(C).join(",")); +delete (C as any).f; +(C as any).f = 4; +console.log(d("f"), Object.keys(C).join(",")); +Object.defineProperty(C, "name", { value: "K" }); +console.log(d("name"), C.name); +delete (C as any).name; +console.log(d("name"), typeof C.name); +try { (C as any).name = "Z"; } catch (e) { console.log("threw name"); } +console.log(d("name"), C.name); +class D { static name = "field"; } +console.log(JSON.stringify(Object.getOwnPropertyDescriptor(D, "name"))); +(D as any).name = "w"; +console.log(D.name); +Object.freeze(C); +console.log(Object.isFrozen(C), d("f")); +try { (C as any).f = 9; } catch (e) { console.log("threw f"); } +console.log((C as any).f); From 8295075398273786b1244523bd9b9532619e42e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 29 Sep 2026 04:44:41 +0000 Subject: [PATCH 03/11] feat(runtime): class static methods are own properties of the class function object (3f, parts 2+3) A ClassBody static method, named or computed, is installed as an own data property {writable, !enumerable, configurable} of the class function object. Its value is the method's own function object, which runs a closure-convention entry `__clo` (enter binds the call's `this`, the body runs, leave). CLASS_DELETED_KEYS is gone: a static method is deleted exactly when the declared key is no longer an own property. Direct call sites (`C.m()`, `Sub.m()`, `(C as any).m()`, a value receiver known to be the class) keep calling the body directly behind a guard. The fact "own m is still the declaration" lives in the class function object's shape: a store, define or delete that replaces a declaration's value transitions the bag's shape (closure/props.rs). Each site keeps a memo of (C's shape word | owner's shape word << 32); the hit is inline (two shape loads and a compare, no runtime call), and a miss re-validates in js_class_static_call_guard / js_class_static_value_call_guard, which re-arm only for a one-link chain. On a failed guard the call goes through [[Get]] + call. Reads that answered from the declaration instead of the object are fixed: the codegen typeof fold over ctx.classes is removed, the prototype chain walk skips a level whose declared method was deleted from its materialized prototype, get_property_attrs reads the closure's bag keys (Object.entries/assign/spread), getOwnPropertyNames lists bag keys in creation order, `prototype` is installed into the bag at mint, and a define of a key the bag does not own appends instead of reviving a tombstoned slot. Tests: test_gap_class_static_method_props.ts, test_gap_class_delete_redefine.ts, test_gap_class_computed_static_method.ts; static_symbol_hygiene counts the new `__clo` definitions. --- .../class-static-methods-own-properties.md | 9 + crates/perry-abi/src/lib.rs | 11 + .../src/codegen/artifact_source_text.rs | 57 ++- .../perry-codegen/src/codegen/string_pool.rs | 150 ++++++- .../perry-codegen/src/expr/literals_vars.rs | 52 +-- crates/perry-codegen/src/expr/mod.rs | 2 +- .../src/expr/static_field_meta.rs | 8 + .../perry-codegen/src/expr/static_method.rs | 237 ++++++++++- .../src/gc_effects/linux-x86_64.tsv | 5 + .../property_get/static_dispatch.rs | 88 +++- .../runtime_decls/stdlib_ffi/language_core.rs | 17 + .../src/runtime_decls/strings_part2.rs | 2 +- .../perry-codegen/src/wasm32/runtime_abi.tsv | 7 +- .../tests/static_symbol_hygiene.rs | 36 +- crates/perry-runtime/src/closure/props.rs | 65 ++- .../src/json/stringify_tojson_probe.rs | 1 - .../src/json/stringify_tojson_probe_tests.rs | 23 +- .../perry-runtime/src/object/class_image.rs | 3 +- .../src/object/class_registry.rs | 39 +- .../src/object/class_registry/construct.rs | 5 +- .../object/class_registry/decl_accessors.rs | 6 +- .../src/object/class_registry/dispatch.rs | 6 +- .../src/object/class_registry/gc_roots.rs | 4 - .../object/class_registry/parent_static.rs | 163 ++++++- .../parent_static/private_and_dynamic.rs | 2 +- .../class_registry/prototype_methods.rs | 3 - .../src/object/class_registry/state.rs | 118 +++--- .../perry-runtime/src/object/class_value.rs | 400 +++++++++++++++++- .../perry-runtime/src/object/delete_rest.rs | 47 +- .../src/object/descriptor_state.rs | 16 + .../perry-runtime/src/object/descriptors.rs | 150 +++---- .../field_get_set/class_object_props.rs | 2 +- .../object/field_get_set/get_field_by_name.rs | 17 +- .../src/object/field_get_set/has_property.rs | 20 +- .../src/object/field_get_set/ic_miss.rs | 3 +- .../src/object/field_set_by_name.rs | 2 +- crates/perry-runtime/src/object/mod.rs | 3 + .../src/object/native_call_method.rs | 7 +- .../native_call_method/common_methods.rs | 10 +- .../native_call_method/handle_methods.rs | 2 +- .../object/native_module/class_ref_values.rs | 2 +- .../src/object/object_ops/define_property.rs | 1 - .../src/object/object_ops/has_own.rs | 20 +- .../perry-runtime/src/object/property_key.rs | 1 + .../perry-runtime/src/object/this_binding.rs | 33 ++ scripts/gc_runtime_root_holders.json | 4 - .../test_gap_class_computed_static_method.ts | 46 ++ test-files/test_gap_class_delete_redefine.ts | 44 ++ .../test_gap_class_static_method_props.ts | 77 ++++ 49 files changed, 1667 insertions(+), 359 deletions(-) create mode 100644 changelog.d/class-static-methods-own-properties.md create mode 100644 test-files/test_gap_class_computed_static_method.ts create mode 100644 test-files/test_gap_class_delete_redefine.ts create mode 100644 test-files/test_gap_class_static_method_props.ts diff --git a/changelog.d/class-static-methods-own-properties.md b/changelog.d/class-static-methods-own-properties.md new file mode 100644 index 0000000000..17acaeb3f9 --- /dev/null +++ b/changelog.d/class-static-methods-own-properties.md @@ -0,0 +1,9 @@ +Fixed class static methods not being ordinary own properties of the class. +A static method, named or computed, is now a writable, non-enumerable, +configurable data property of the class function object, whose value is the +method's own function object: `Object.getOwnPropertyDescriptor(C, "m").value +=== C.m`, `C.m === Sub.m`, and replacing, redefining (`Object.defineProperty`, +`Reflect.set`, `Object.assign`) or deleting it is seen by every later call, +including `C.m()` call sites compiled before the change. A deleted static or +prototype method no longer reappears on read, and `getOwnPropertyNames` lists +a class's keys in creation order. diff --git a/crates/perry-abi/src/lib.rs b/crates/perry-abi/src/lib.rs index 61ffa65514..18b6345414 100644 --- a/crates/perry-abi/src/lib.rs +++ b/crates/perry-abi/src/lib.rs @@ -41,6 +41,17 @@ pub const CLOSURE_FUNC_PTR_OFFSET: usize = 8; pub const CLOSURE_PROPS_OFFSET: usize = 16; pub const CLOSURE_HEADER_SIZE: usize = 24; +/// `object::ObjectHeader::parent_class_id`: the object's ShapeId word (LP64 +/// and ILP32 alike; `CLOSURE_SHAPE_OFFSET` is the same word of a closure). +pub const OBJECT_SHAPE_OFFSET: usize = 4; + +/// `object::class_value::StaticCallMemo` (LP64) — the words the emitted +/// static-call guard reads (`perry-codegen/src/expr/static_method.rs`). +pub const STATIC_CALL_MEMO_KEY_OFFSET: usize = 0; +pub const STATIC_CALL_MEMO_C_OFFSET: usize = 8; +pub const STATIC_CALL_MEMO_OWNER_OFFSET: usize = 16; +pub const STATIC_CALL_MEMO_VALUE_OFFSET: usize = 24; + /// `gc::GC_TYPE_CLOSURE`: the GcHeader type byte (at payload - 8) that makes a /// cell a function object. The kind is this byte, never a payload magic. pub const GC_TYPE_CLOSURE: u8 = 4; diff --git a/crates/perry-codegen/src/codegen/artifact_source_text.rs b/crates/perry-codegen/src/codegen/artifact_source_text.rs index 4126144511..ccfd025861 100644 --- a/crates/perry-codegen/src/codegen/artifact_source_text.rs +++ b/crates/perry-codegen/src/codegen/artifact_source_text.rs @@ -29,6 +29,7 @@ pub(super) fn extend_class_method_source_text( .iter() .map(|(symbol, _, _)| symbol.clone()) .collect(); + let mut entry_sources: Vec<(String, String, bool)> = Vec::new(); let mut push_defined = |func_id: FuncId, symbol: String| { let Some(source) = hir.closure_source_text.get(&func_id) else { return; @@ -103,27 +104,59 @@ pub(super) fn extend_class_method_source_text( push_defined(setter.id, symbol); } for method in &class.static_methods { - push_defined( - method.id, - scoped_static_method_name(module_prefix, class.id, &class.name, &method.name), - ); + let body = + scoped_static_method_name(module_prefix, class.id, &class.name, &method.name); + // The method's own function object runs `__clo` (string + // pool): its toString is the method's source too. + if !method.name.starts_with("__perry_static_init_") && llmod.has_function(&body) { + if let Some(source) = hir.closure_source_text.get(&method.id) { + entry_sources.push(( + format!("{body}__clo"), + super::function_source_header::retained_function_text( + hir, + closures, + method.id, + &source.text, + ), + source.is_non_strict_ordinary, + )); + } + } + push_defined(method.id, body); } for member in class .computed_members .iter() .filter(|member| member.is_static) { - push_defined( - member.function.id, - scoped_static_method_name( - module_prefix, - class.id, - &class.name, - &member.function.name, - ), + let body = scoped_static_method_name( + module_prefix, + class.id, + &class.name, + &member.function.name, ); + // A computed-name static method's function object runs + // `__clo` too (string pool). + if matches!(member.kind, perry_hir::ClassComputedMemberKind::Method) + && llmod.has_function(&body) + { + if let Some(source) = hir.closure_source_text.get(&member.function.id) { + entry_sources.push(( + format!("{body}__clo"), + super::function_source_header::retained_function_text( + hir, + closures, + member.function.id, + &source.text, + ), + source.is_non_strict_ordinary, + )); + } + } + push_defined(member.function.id, body); } } + user_fn_source.extend(entry_sources); } /// Collect retained `Function.prototype.toString` source text for every user diff --git a/crates/perry-codegen/src/codegen/string_pool.rs b/crates/perry-codegen/src/codegen/string_pool.rs index 203c1972f7..95731d3722 100644 --- a/crates/perry-codegen/src/codegen/string_pool.rs +++ b/crates/perry-codegen/src/codegen/string_pool.rs @@ -50,6 +50,12 @@ impl<'a> InitChunker<'a> { /// Start a fresh chunk function if the current one is full. Call ONCE at the /// top of each loop iteration (one independent init op), before /// [`current_block`]. Closes the previous chunk with `ret void`. + /// The module, for a definition an init op registers (the chunk being + /// filled is addressed by index, so appending functions is fine). + fn module(&mut self) -> &mut LlModule { + self.llmod + } + fn roll_if_full(&mut self) { if self.ops_in_current >= self.ops_per_chunk { if !self.chunk_names.is_empty() { @@ -867,7 +873,10 @@ pub(super) fn emit_string_pool( // subclass whose parent is a class-expression value inherits the parent's // static methods (`class Sub extends make(...) {}; Sub.greet()`); has_rest // tells the dispatcher to bundle trailing args for a `...rest` param. - let mut static_method_triples: Vec<(u32, String, String, u32, bool, u32, u32)> = Vec::new(); + #[allow(clippy::type_complexity)] + let mut static_method_triples: Vec<(u32, String, String, u32, bool, u32, u32, bool, bool)> = + Vec::new(); + let mut computed_static_entries: Vec = Vec::new(); // #1787: (cid, standalone-constructor symbol, total_param_count). // Registered into CLASS_CONSTRUCTORS so `new ()` (a // class-expression value constructed dynamically) can replay the class's @@ -996,8 +1005,42 @@ pub(super) fn emit_string_pool( has_rest, spec_length, sm.id, + sm.params + .iter() + .any(|p| p.is_rest && p.arguments_object.is_none()), + sm.params.iter().any(|p| p.arguments_object.is_some()), )); } + // A computed-name static method is a ClassBody static method too: its + // own function object runs the same closure-convention entry. Its + // key (and so its `name`) exists only when the class definition + // evaluates, which registers the entry with it + // (`js_register_class_computed_method`). + for member in class + .computed_members + .iter() + .filter(|m| m.is_static && matches!(m.kind, perry_hir::ClassComputedMemberKind::Method)) + { + let f = &member.function; + let mut spec_length = 0u32; + for p in &f.params { + if p.arguments_object.is_some() || p.is_rest || p.default.is_some() { + break; + } + spec_length += 1; + } + computed_static_entries.push(StaticMethodEntry { + cid, + llvm_name: scoped_static_method_name(module_prefix, cid, class_name, &f.name), + param_count: f.params.len() as u32, + spec_length, + has_user_rest: f + .params + .iter() + .any(|p| p.is_rest && p.arguments_object.is_none()), + has_synth_args: f.params.iter().any(|p| p.arguments_object.is_some()), + }); + } // #1787: the standalone constructor `___constructor` // (emitted unconditionally in `artifacts.rs`). Its arity is the // constructor's full param list — user params plus the synthesized @@ -1145,8 +1188,17 @@ pub(super) fn emit_string_pool( // static methods (subclass extends a class-expression value) resolve at // runtime via the class_id parent-chain walk. static_method_triples.sort_unstable(); - for (cid, method_name, llvm_name, param_count, has_rest, spec_length, definition_order) in - static_method_triples + for ( + cid, + method_name, + llvm_name, + param_count, + has_rest, + spec_length, + definition_order, + has_user_rest, + has_synth_args, + ) in static_method_triples { chunker.roll_if_full(); let blk = chunker.current_block(); @@ -1193,6 +1245,37 @@ pub(super) fn emit_string_pool( (I64, &spec_length.to_string()), ], ); + let entry_ref = emit_static_method_entry( + &mut chunker, + &StaticMethodEntry { + cid, + llvm_name: llvm_name.clone(), + param_count, + spec_length, + has_user_rest, + has_synth_args, + }, + ); + let blk = chunker.current_block(); + blk.call_void( + register_name_fn, + &[(PTR, &entry_ref), (PTR, &bytes_global), (I32, &len_str)], + ); + let entry_i64 = blk.ptrtoint(&entry_ref, I64); + blk.call_void( + "js_register_class_static_method_entry", + &[ + (I64, &cid.to_string()), + (I64, &bytes_i64), + (I64, &len_str), + (I64, &entry_i64), + ], + ); + } + computed_static_entries.sort_unstable_by(|a, b| a.llvm_name.cmp(&b.llvm_name)); + for e in &computed_static_entries { + chunker.roll_if_full(); + emit_static_method_entry(&mut chunker, e); } // #1787: register each class's standalone constructor into // CLASS_CONSTRUCTORS. ptrtoint @symbol both stores the function pointer @@ -1822,3 +1905,64 @@ pub(super) fn emit_string_pool( #[cfg(test)] #[path = "class_name_registration_tests.rs"] mod class_name_registration_tests; + +/// A ClassBody static method's closure-convention entry (`__clo`). +struct StaticMethodEntry { + cid: u32, + llvm_name: String, + param_count: u32, + spec_length: u32, + has_user_rest: bool, + has_synth_args: bool, +} + +/// Define `__clo(closure, args...)`, the code of a ClassBody static +/// method's own function object: the call's `this` becomes the body's `this` +/// (enter), the body runs, leave drops what enter set up. Arity, rest +/// bundling, length and strictness are registered on the code, as for any +/// function (the caller registers the name). Returns `@__clo`. +fn emit_static_method_entry(chunker: &mut InitChunker<'_>, e: &StaticMethodEntry) -> String { + let entry_name = format!("{}__clo", e.llvm_name); + { + let n = e.param_count as usize; + let mut params: Vec<(crate::types::LlvmType, String)> = + vec![(I64, "%this_closure".to_string())]; + params.extend((0..n).map(|i| (DOUBLE, format!("%a{}", i)))); + let f = chunker + .module() + .define_function(&entry_name, DOUBLE, params); + let _ = f.create_block("entry"); + let b = f.block_mut(0).unwrap(); + b.call_void("js_static_method_entry_enter", &[(I32, &e.cid.to_string())]); + let arg_names: Vec = (0..n).map(|i| format!("%a{}", i)).collect(); + let call_args: Vec<(crate::types::LlvmType, &str)> = + arg_names.iter().map(|a| (DOUBLE, a.as_str())).collect(); + let r = b.call(DOUBLE, &e.llvm_name, &call_args); + b.call_void("js_static_method_entry_leave", &[]); + b.ret(DOUBLE, &r); + } + let blk = chunker.current_block(); + let entry_ref = format!("@{}", entry_name); + let (shape_fn, shape_count) = match (e.has_user_rest, e.has_synth_args) { + (true, true) => ( + "js_register_closure_rest_and_arguments", + e.param_count.saturating_sub(2), + ), + (true, false) => ("js_register_closure_rest", e.param_count.saturating_sub(1)), + (false, true) => ( + "js_register_closure_synthetic_arguments", + e.param_count.saturating_sub(1), + ), + (false, false) => ("js_register_closure_arity", e.param_count), + }; + blk.call_void( + shape_fn, + &[(PTR, &entry_ref), (I32, &shape_count.to_string())], + ); + blk.call_void( + "js_register_closure_length", + &[(PTR, &entry_ref), (I32, &e.spec_length.to_string())], + ); + blk.call_void("js_register_closure_strict_function", &[(PTR, &entry_ref)]); + entry_ref +} diff --git a/crates/perry-codegen/src/expr/literals_vars.rs b/crates/perry-codegen/src/expr/literals_vars.rs index 992ca93074..a21615c3ea 100644 --- a/crates/perry-codegen/src/expr/literals_vars.rs +++ b/crates/perry-codegen/src/expr/literals_vars.rs @@ -1116,52 +1116,12 @@ pub(crate) fn typeof_compile_time_answer(ctx: &FnCtx<'_>, operand: &Expr) -> Opt } } } else { - // Refs #915 (gap 2 from #899): `typeof C.staticMethod` - // where `C` is `Expr::ClassRef` or a `LocalGet` - // aliased to a class. Without this fold, the - // generic PropertyGet path returns `undefined` - // for static methods (the runtime `class_has_own_method` - // checks the prototype vtable, not the static - // method registry), so `typeof Cls.pipe` reported - // `"undefined"` instead of `"function"`. The actual - // dispatch fix lives in `lower_call.rs`'s ClassRef - // static-method arm — but a typeof read isn't a - // call, so it needs its own fold here. - let cls_opt: Option = match object.as_ref() { - Expr::ClassRef(cls_name) => Some(cls_name.clone()), - Expr::LocalGet(id) => ctx - .local_id_to_name - .get(id) - .and_then(|name| ctx.local_class_aliases.get(name).cloned()), - _ => None, - }; - if let Some(cls) = cls_opt { - // Walk own static methods + extends chain. - let mut cur = Some(cls); - let mut found = false; - while let Some(c) = cur { - if let Some(class_info) = ctx.classes.get(&c) { - if class_info - .static_methods - .iter() - .any(|m| m.name == *property) - { - found = true; - break; - } - cur = class_info.extends_name.clone(); - } else { - break; - } - } - if found { - Some("function") - } else { - None - } - } else { - None - } + // A class static method is an own property of the class + // function object (charter step 3f): the generic read returns + // its current value, so `typeof C.m` after `delete C.m` or + // `C.m = 5` must come from that read, never from the + // declaration list. + None } } _ => None, diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 9748b072a5..134ac5be31 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -3108,7 +3108,7 @@ pub(crate) mod proxy_reflect; pub(crate) mod put_value_store_ic; pub(crate) mod receiver_range; mod static_field_meta; -mod static_method; +pub(crate) mod static_method; pub(crate) mod store_census; mod string_regex_proc; mod super_method; diff --git a/crates/perry-codegen/src/expr/static_field_meta.rs b/crates/perry-codegen/src/expr/static_field_meta.rs index 1a2c5bacc4..6efda5efe1 100644 --- a/crates/perry-codegen/src/expr/static_field_meta.rs +++ b/crates/perry-codegen/src/expr/static_field_meta.rs @@ -503,6 +503,13 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let is_static_str = (*is_static as i64).to_string(); let has_rest_str = (*has_rest as i64).to_string(); let definition_order_str = definition_order.to_string(); + // A static one's own function object runs its + // closure-convention entry (string pool). + let entry_i64 = if *is_static { + ctx.block().ptrtoint(&format!("@{}__clo", llvm_name), I64) + } else { + "0".to_string() + }; ctx.block().call_void( "js_register_class_computed_method", &[ @@ -513,6 +520,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { (I64, &is_static_str), (I64, &has_rest_str), (I64, &definition_order_str), + (I64, &entry_i64), ], ); } diff --git a/crates/perry-codegen/src/expr/static_method.rs b/crates/perry-codegen/src/expr/static_method.rs index f3c6d2d1cf..e696c60cfc 100644 --- a/crates/perry-codegen/src/expr/static_method.rs +++ b/crates/perry-codegen/src/expr/static_method.rs @@ -17,6 +17,112 @@ use super::{ import_origin_suffix_ns, lower_expr, nanbox_pointer_inline, unbox_to_i64, FnCtx, }; +/// A static-call site's guard (`js_class_static_call_guard`): the declared +/// body runs directly only while the property the call reads is still that +/// declaration's function object, a fact carried by the class function +/// objects' shapes. The site's memo is a runtime `StaticCallMemo` (four +/// words; thread-local when the program starts workers, so each agent arms +/// its own). The hit is inline: +/// +/// ```text +/// c = memo.c ; c != 0 [&& receiver == memo.c_value] else MISS +/// k = [[c + PROPS] + SHAPE] | [[memo.owner + PROPS] + SHAPE] << 32 +/// k == memo.key else MISS +/// MISS: ok = miss_fn(miss_args..., memo) (re-validates, re-arms) +/// ``` +/// +/// `same_owner`: the class the call names declares the body itself, so one +/// shape word is both halves. `receiver_bits`: a site whose receiver is a +/// value must also be looking at the memo's class function object. Returns +/// the i1 "the body may run directly". +pub(crate) fn emit_static_call_guard( + ctx: &mut FnCtx<'_>, + receiver_bits: Option<&str>, + same_owner: bool, + miss_fn: &str, + miss_args: &[(crate::types::LlvmType, String)], +) -> String { + use crate::types::I1; + let site = ctx.ic_site_counter; + ctx.ic_site_counter += 1; + let memo = format!( + "@{}_smemo", + crate::expr::inline_cache_global_name(ctx, site) + ); + let tls = if crate::codegen::program_has_worker() { + "thread_local " + } else { + "" + }; + ctx.typed_parse_rodata.push(format!( + "{memo} = private {tls}global [4 x i64] [i64 -1, i64 0, i64 0, i64 0], align 8" + )); + let mut args: Vec<(crate::types::LlvmType, &str)> = + miss_args.iter().map(|(t, v)| (*t, v.as_str())).collect(); + args.push((PTR, &memo)); + // The inline hit reads LP64 layouts (8-byte memo words, `ClosureHeader` + // props at 16); other targets always ask the runtime. + let triple = ctx.target_triple; + let lp64 = + (triple.starts_with("x86_64") || triple.starts_with("aarch64")) && !triple.contains("32"); + if !lp64 { + let ok = ctx.block().call(I32, miss_fn, &args); + return ctx.block().icmp_ne(I32, &ok, "0"); + } + let word = |ctx: &mut FnCtx<'_>, offset: usize| -> String { + let p = ctx + .block() + .gep(crate::types::I8, &memo, &[(I64, &offset.to_string())]); + ctx.block().load(I64, &p) + }; + let shape_word = |ctx: &mut FnCtx<'_>, fo: &str| -> String { + let fo = ctx.block().inttoptr(I64, fo); + let props = crate::runtime_abi::CLOSURE_PROPS_OFFSET.to_string(); + let pp = ctx.block().gep(crate::types::I8, &fo, &[(I64, &props)]); + let bag = ctx.block().load(PTR, &pp); + let shape = crate::runtime_abi::OBJECT_SHAPE_OFFSET.to_string(); + let sp = ctx.block().gep(crate::types::I8, &bag, &[(I64, &shape)]); + let w = ctx.block().load(I32, &sp); + ctx.block().zext(I32, &w, I64) + }; + let check_idx = ctx.new_block("static_guard.check"); + let miss_idx = ctx.new_block("static_guard.miss"); + let join_idx = ctx.new_block("static_guard.join"); + let check_l = ctx.block_label(check_idx); + let miss_l = ctx.block_label(miss_idx); + let join_l = ctx.block_label(join_idx); + let c = word(ctx, crate::runtime_abi::STATIC_CALL_MEMO_C_OFFSET); + let mut armed = ctx.block().icmp_ne(I64, &c, "0"); + if let Some(bits) = receiver_bits { + let v = word(ctx, crate::runtime_abi::STATIC_CALL_MEMO_VALUE_OFFSET); + let same = ctx.block().icmp_eq(I64, bits, &v); + armed = ctx.block().and(I1, &armed, &same); + } + ctx.block().cond_br(&armed, &check_l, &miss_l); + ctx.current_block = check_idx; + let sc = shape_word(ctx, &c); + let so = if same_owner { + sc.clone() + } else { + let o = word(ctx, crate::runtime_abi::STATIC_CALL_MEMO_OWNER_OFFSET); + shape_word(ctx, &o) + }; + let hi = ctx.block().shl(I64, &so, "32"); + let key = ctx.block().or(I64, &sc, &hi); + let memo_key = word(ctx, crate::runtime_abi::STATIC_CALL_MEMO_KEY_OFFSET); + let hit = ctx.block().icmp_eq(I64, &key, &memo_key); + let check_pred = ctx.block().label.clone(); + ctx.block().cond_br(&hit, &join_l, &miss_l); + ctx.current_block = miss_idx; + let ok = ctx.block().call(I32, miss_fn, &args); + let ok = ctx.block().icmp_ne(I32, &ok, "0"); + let miss_pred = ctx.block().label.clone(); + ctx.block().br(&join_l); + ctx.current_block = join_idx; + ctx.block() + .phi(I1, &[("true", &check_pred), (&ok, &miss_pred)]) +} + fn downgrade_unknown_call_args(ctx: &mut FnCtx<'_>, args: &[Expr]) { for arg in args { downgrade_buffer_aliases_in_expr(ctx, arg, MaterializationReason::UnknownCallEscape); @@ -131,17 +237,88 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { .get(class_name) .map(|c| c.static_methods.iter().any(|m| m.name == *method_name)) .unwrap_or(true); - if !owns_method { - if let Some(&cid) = ctx.class_ids.get(class_name) { + // The shape is the authority: the body runs directly only while + // the property a call reads (`C.m`, on C or the class it is + // inherited from) is still this declaration's function object. + // The check precedes the arguments, as the property read does; + // otherwise the call reads the property and calls its value on + // the class function object (pinned, so it survives the + // argument evaluation). + // A `static { }` block's synthetic method is not a member: the + // class initializer calls it directly, it has no property. + let static_cid = if method_name.starts_with("__perry_static_init_") { + None + } else { + ctx.class_ids.get(class_name).copied() + }; + // The class whose ClassBody declares the body this call runs. + let owner_cid = { + let mut cur = class_name.clone(); + let mut found = 0u32; + for _ in 0..32 { + let Some(c) = ctx.classes.get(&cur) else { + break; + }; + if c.static_methods.iter().any(|m| m.name == *method_name) { + found = ctx.class_ids.get(&cur).copied().unwrap_or(0); + break; + } + match c.extends_name.clone() { + Some(p) => cur = p, + None => break, + } + } + found + }; + let name_idx = ctx.strings.intern(method_name); + let name_entry = ctx.strings.entry(name_idx); + let name_bytes = format!("@{}", name_entry.bytes_global); + let name_len = name_entry.byte_len.to_string(); + let guard = match static_cid { + Some(cid) => { let cid_str = cid.to_string(); - ctx.block() - .call_void("js_static_this_arm_classref", &[(I32, &cid_str)]); + // Per-site memo of the class function objects' shapes + // that proved the declaration (validated per use). + let body_i64 = ctx.block().ptrtoint(&format!("@{}", fn_name), I64); + let ok = emit_static_call_guard( + ctx, + None, + owner_cid == cid, + "js_class_static_call_guard", + &[ + (I32, cid_str.clone()), + (I32, owner_cid.to_string()), + (PTR, name_bytes.clone()), + (I64, name_len.clone()), + (I64, body_i64), + ], + ); + let recv_idx = ctx.new_block("static_call.receiver"); + let args_idx = ctx.new_block("static_call.args"); + let recv_label = ctx.block_label(recv_idx); + let args_label = ctx.block_label(args_idx); + let pre_label = ctx.block().label.clone(); + ctx.block().cond_br(&ok, &args_label, &recv_label); + ctx.current_block = recv_idx; + let recv = ctx + .block() + .call(DOUBLE, "js_class_value", &[(I32, &cid_str)]); + let recv_pred = ctx.block().label.clone(); + ctx.block().br(&args_label); + ctx.current_block = args_idx; + let undef = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + let recv = ctx + .block() + .phi(DOUBLE, &[(&undef, &pre_label), (&recv, &recv_pred)]); + Some((ok, recv)) } - } + None => None, + }; let mut lowered: Vec = Vec::with_capacity(args.len()); for a in args { lowered.push(lower_expr(ctx, a)?); } + let raw_args = lowered.clone(); // Issue #894: static methods with synthetic `...arguments` // rest params (or any user-declared rest param) need their // trailing args bundled into an array. Without this, @@ -243,7 +420,55 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } let arg_slices: Vec<(crate::types::LlvmType, &str)> = lowered.iter().map(|s| (DOUBLE, s.as_str())).collect(); - return Ok(ctx.block().call(DOUBLE, &fn_name, &arg_slices)); + let Some((ok, recv)) = guard else { + return Ok(ctx.block().call(DOUBLE, &fn_name, &arg_slices)); + }; + let direct_idx = ctx.new_block("static_call.direct"); + let generic_idx = ctx.new_block("static_call.property"); + let join_idx = ctx.new_block("static_call.join"); + let direct_label = ctx.block_label(direct_idx); + let generic_label = ctx.block_label(generic_idx); + let join_label = ctx.block_label(join_idx); + ctx.block().cond_br(&ok, &direct_label, &generic_label); + ctx.current_block = direct_idx; + if !owns_method { + let cid_str = static_cid.unwrap_or(0).to_string(); + ctx.block() + .call_void("js_static_this_arm_classref", &[(I32, &cid_str)]); + } + let direct = ctx.block().call(DOUBLE, &fn_name, &arg_slices); + let direct_pred = ctx.block().label.clone(); + ctx.block().br(&join_label); + ctx.current_block = generic_idx; + let (args_ptr, args_len) = if raw_args.is_empty() { + ("null".to_string(), "0".to_string()) + } else { + let buf = ctx.func.alloca_entry_array(DOUBLE, raw_args.len()); + let blk = ctx.block(); + for (i, value) in raw_args.iter().enumerate() { + let slot = blk.gep(DOUBLE, &buf, &[(I64, &i.to_string())]); + blk.store(DOUBLE, value, &slot); + } + (buf, raw_args.len().to_string()) + }; + let via_property = ctx.block().call( + DOUBLE, + "js_native_call_method", + &[ + (DOUBLE, &recv), + (PTR, &name_bytes), + (I64, &name_len), + (PTR, &args_ptr), + (I64, &args_len), + ], + ); + let generic_pred = ctx.block().label.clone(); + ctx.block().br(&join_label); + ctx.current_block = join_idx; + return Ok(ctx.block().phi( + DOUBLE, + &[(&direct, &direct_pred), (&via_property, &generic_pred)], + )); } // #310: when the receiver is a namespace alias from an // `import { Foo } from "pkg"` where the source module did diff --git a/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv b/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv index 9d6ab0aeef..d53ecfed0e 100644 --- a/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv +++ b/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv @@ -601,9 +601,11 @@ js_class_prototype_method_value Reenters js_class_register_capture_values Leaf js_class_register_static_field Reenters js_class_register_static_symbol Reenters +js_class_static_call_guard Reenters js_class_static_field_get Reenters js_class_static_field_put Reenters js_class_static_method_call Reenters +js_class_static_value_call_guard Reenters js_class_value Reenters js_clear_exception Leaf js_clear_immediate_value Reenters @@ -2750,6 +2752,7 @@ js_register_class_source_static Leaf js_register_class_static_getter Reenters js_register_class_static_method Reenters js_register_class_static_method_bind_length Leaf +js_register_class_static_method_entry Reenters js_register_class_static_setter Reenters js_register_class_string_member_order Leaf js_register_class_to_string_tag Leaf @@ -3042,6 +3045,8 @@ js_stack_overflow Reenters js_state_get Reenters js_state_init Reenters js_state_set Reenters +js_static_method_entry_enter Reenters +js_static_method_entry_leave Leaf js_static_this_arm_classref Reenters js_static_this_arm_value Leaf js_static_this_resolve Leaf diff --git a/crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs b/crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs index 0adb171ff5..eeb3643143 100644 --- a/crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs +++ b/crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs @@ -43,6 +43,8 @@ pub(crate) fn try_lower_static_dispatch( } // (fn_name, is_static, declared_param_count, has_rest, is_synthetic_arguments) let mut resolved: Option<(String, bool, usize, bool, bool)> = None; + // The class whose ClassBody declares the resolved body. + let mut owner_class: Option = None; let mut cur = Some(cls_name.clone()); while let Some(c) = cur { if let Some(class_info) = ctx.classes.get(&c) { @@ -64,6 +66,7 @@ pub(crate) fn try_lower_static_dispatch( .map(|p| p.arguments_object.is_some()) .unwrap_or(false); resolved = Some((fname, true, declared, has_rest, is_synth_args)); + owner_class = Some(c.clone()); break; } } @@ -151,6 +154,79 @@ pub(crate) fn try_lower_static_dispatch( has_rest || crate::rooting::any_operand_may_collect(ctx, args.iter()); let recv_idx = group.adopt(ctx, object, &recv_box, collects); + // The shape is the authority: the declared body runs directly + // only while `.` is still that declaration's + // function object (`js_class_static_value_call_guard`, checked + // before the arguments, as the property read is). Otherwise the + // call reads the property and calls its value. + let owner_cid = owner_class + .as_ref() + .and_then(|c| ctx.class_ids.get(c).copied()) + .filter(|&c| c != 0 && !property.starts_with("__perry_static_init_")); + let guarded = if let Some(owner_cid) = owner_cid { + let name_idx = ctx.strings.intern(property); + let name_entry = ctx.strings.entry(name_idx); + let name_bytes = format!("@{}", name_entry.bytes_global); + let name_len = name_entry.byte_len.to_string(); + let recv_now = group.reread(ctx, recv_idx)?; + let recv_bits = ctx.block().bitcast_double_to_i64(&recv_now); + let body_i64 = ctx.block().ptrtoint(&format!("@{}", fn_name), I64); + let ok = crate::expr::static_method::emit_static_call_guard( + ctx, + Some(&recv_bits), + false, + "js_class_static_value_call_guard", + &[ + (DOUBLE, recv_now.clone()), + (I32, owner_cid.to_string()), + (crate::types::PTR, name_bytes.clone()), + (I64, name_len.clone()), + (I64, body_i64), + ], + ); + let direct_idx = ctx.new_block("static_value_call.direct"); + let generic_idx = ctx.new_block("static_value_call.property"); + let join_idx = ctx.new_block("static_value_call.join"); + let direct_label = ctx.block_label(direct_idx); + let generic_label = ctx.block_label(generic_idx); + ctx.block().cond_br(&ok, &direct_label, &generic_label); + ctx.current_block = generic_idx; + let mut raw: Vec = Vec::with_capacity(args.len()); + for a in args { + raw.push(lower_expr(ctx, a)?); + } + let (args_ptr, args_len) = if raw.is_empty() { + ("null".to_string(), "0".to_string()) + } else { + let buf = ctx.func.alloca_entry_array(DOUBLE, raw.len()); + let blk = ctx.block(); + for (i, value) in raw.iter().enumerate() { + let slot = blk.gep(DOUBLE, &buf, &[(I64, &i.to_string())]); + blk.store(DOUBLE, value, &slot); + } + (buf, raw.len().to_string()) + }; + let recv_g = group.reread(ctx, recv_idx)?; + let via_property = ctx.block().call( + DOUBLE, + "js_native_call_method", + &[ + (DOUBLE, &recv_g), + (crate::types::PTR, &name_bytes), + (I64, &name_len), + (crate::types::PTR, &args_ptr), + (I64, &args_len), + ], + ); + let generic_pred = ctx.block().label.clone(); + let join_label = ctx.block_label(join_idx); + ctx.block().br(&join_label); + ctx.current_block = direct_idx; + Some((via_property, generic_pred, join_idx)) + } else { + None + }; + // Refs #915 (gap 3 / #321 follow-up): Effect's `class // SchemaClass { static pipe() { ... arguments ... } }` // factory returns an anon class whose `pipe` reads @@ -280,7 +356,17 @@ pub(crate) fn try_lower_static_dispatch( lowered.iter().map(|s| (DOUBLE, s.as_str())).collect(); let result = ctx.block().call(DOUBLE, &fn_name, &arg_slices); crate::rooting::implicit_this_restore(ctx, prev_this); - Ok(Some(result)) + let Some((via_property, generic_pred, join_idx)) = guarded else { + return Ok(Some(result)); + }; + let direct_pred = ctx.block().label.clone(); + let join_label = ctx.block_label(join_idx); + ctx.block().br(&join_label); + ctx.current_block = join_idx; + Ok(Some(ctx.block().phi( + DOUBLE, + &[(&result, &direct_pred), (&via_property, &generic_pred)], + ))) }); } // #1787 / #321: the call target is a static FIELD holding a callable, diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs index 5f6e56a826..2dcb9f923b 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs @@ -400,6 +400,23 @@ pub(crate) fn declare_core(module: &mut LlModule) { module.declare_function("js_static_this_resolve", DOUBLE, &[DOUBLE]); module.declare_function("js_static_this_resolve_class", DOUBLE, &[I32, PTR]); module.declare_function("js_static_this_arm_classref", VOID, &[I32]); + module.declare_function("js_static_method_entry_enter", VOID, &[I32]); + module.declare_function("js_static_method_entry_leave", VOID, &[]); + module.declare_function( + "js_class_static_call_guard", + I32, + &[I32, I32, PTR, I64, I64, PTR], + ); + module.declare_function( + "js_class_static_value_call_guard", + I32, + &[DOUBLE, I32, PTR, I64, I64, PTR], + ); + module.declare_function( + "js_register_class_static_method_entry", + VOID, + &[I64, I64, I64, I64], + ); module.declare_function("js_static_this_arm_value", VOID, &[DOUBLE]); module.declare_function("js_ctor_return_override", DOUBLE, &[DOUBLE, DOUBLE, I32]); module.declare_function("js_new_target_get", DOUBLE, &[]); diff --git a/crates/perry-codegen/src/runtime_decls/strings_part2.rs b/crates/perry-codegen/src/runtime_decls/strings_part2.rs index 2e4bd2d86a..77ee34bee6 100644 --- a/crates/perry-codegen/src/runtime_decls/strings_part2.rs +++ b/crates/perry-codegen/src/runtime_decls/strings_part2.rs @@ -246,7 +246,7 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) { module.declare_function( "js_register_class_computed_method", VOID, - &[I64, DOUBLE, I64, I64, I64, I64, I64], + &[I64, DOUBLE, I64, I64, I64, I64, I64, I64], ); module.declare_function( "js_register_class_computed_accessor", diff --git a/crates/perry-codegen/src/wasm32/runtime_abi.tsv b/crates/perry-codegen/src/wasm32/runtime_abi.tsv index 4a061bf583..9d65d79bd1 100644 --- a/crates/perry-codegen/src/wasm32/runtime_abi.tsv +++ b/crates/perry-codegen/src/wasm32/runtime_abi.tsv @@ -609,9 +609,11 @@ js_class_prototype_method_value f64 f64,f64 js_class_register_capture_values void i32u,ptr,usize js_class_register_static_field void i32u,ptr,usize,f64,ptr js_class_register_static_symbol void i32u,f64,f64 +js_class_static_call_guard i32s i32s,i32s,ptr,i64,i64,ptr js_class_static_field_get f64 i32s,ptr,i64 js_class_static_field_put void i32s,ptr,i64,f64 js_class_static_method_call f64 f64,ptr,usize,ptr,usize +js_class_static_value_call_guard i32s f64,i32s,ptr,i64,i64,ptr js_class_value f64 i32s js_clear_exception void js_clear_immediate_value void f64 @@ -3205,7 +3207,7 @@ js_register_aux_has_active void ptr js_register_aux_pump void ptr js_register_aux_tick_begin void ptr js_register_class_computed_accessor void i64,f64,i64,i64,i64,i64 -js_register_class_computed_method void i64,f64,i64,i64,i64,i64,i64 +js_register_class_computed_method void i64,f64,i64,i64,i64,i64,i64,i64 js_register_class_constructor void i64,i64,i64,i64 js_register_class_constructor_flags void i64,i64,i64 js_register_class_extends_data_view void i32u @@ -3227,6 +3229,7 @@ js_register_class_source_static void i32u,ptr,i32u js_register_class_static_getter void i64,ptr,i64,i64 js_register_class_static_method void i64,ptr,i64,i64,i64,i64 js_register_class_static_method_bind_length void i64,ptr,i64,i64 +js_register_class_static_method_entry void i64,ptr,i64,i64 js_register_class_static_setter void i64,ptr,i64,i64 js_register_class_string_member_order void i64,ptr,i64,i64,i64 js_register_class_to_string_tag void i32u,i64 @@ -3526,6 +3529,8 @@ js_stack_overflow void js_state_get f64 f64 js_state_init void f64,f64 js_state_set void f64,f64 +js_static_method_entry_enter void i32u +js_static_method_entry_leave void js_static_this_arm_classref void i32u js_static_this_arm_value void f64 js_static_this_resolve f64 f64 diff --git a/crates/perry-codegen/tests/static_symbol_hygiene.rs b/crates/perry-codegen/tests/static_symbol_hygiene.rs index 302cece1e4..5c2027de85 100644 --- a/crates/perry-codegen/tests/static_symbol_hygiene.rs +++ b/crates/perry-codegen/tests/static_symbol_hygiene.rs @@ -219,17 +219,38 @@ fn duplicate_class_static_methods_use_class_id_in_symbols() { let ir = String::from_utf8(compile_module(&duplicate_static_module(), empty_opts()).unwrap()) .unwrap(); + // The body: exactly one definition. assert_eq!( count( &ir, - "define double @perry_static_marked_symbol_hygiene_ts__x__c11__lex" + "define double @perry_static_marked_symbol_hygiene_ts__x__c11__lex(" ), 1 ); + // This module hands codegen two classes of one name; the registration + // pass reads the name-keyed class table, which keeps the last (c12), so + // only its static method is registered and gets the function-object + // entry. (A real module's classes never share a table name.) assert_eq!( count( &ir, - "define double @perry_static_marked_symbol_hygiene_ts__x__c12__lex" + "define double @perry_static_marked_symbol_hygiene_ts__x__c11__lex__clo(" + ), + 0 + ); + // The body, and its function object's closure-convention entry + // `__clo`: exactly one definition each. + assert_eq!( + count( + &ir, + "define double @perry_static_marked_symbol_hygiene_ts__x__c12__lex(" + ), + 1 + ); + assert_eq!( + count( + &ir, + "define double @perry_static_marked_symbol_hygiene_ts__x__c12__lex__clo(" ), 1 ); @@ -256,10 +277,19 @@ fn static_and_instance_methods_with_same_name_keep_distinct_symbols() { ), 1 ); + // The body, and its function object's closure-convention entry + // `__clo`: exactly one definition each. + assert_eq!( + count( + &ir, + "define double @perry_static_static_instance_symbol_hygiene_ts__x__c11__lex(" + ), + 1 + ); assert_eq!( count( &ir, - "define double @perry_static_static_instance_symbol_hygiene_ts__x__c11__lex" + "define double @perry_static_static_instance_symbol_hygiene_ts__x__c11__lex__clo(" ), 1 ); diff --git a/crates/perry-runtime/src/closure/props.rs b/crates/perry-runtime/src/closure/props.rs index f3c486f3fc..141a5f0c35 100644 --- a/crates/perry-runtime/src/closure/props.rs +++ b/crates/perry-runtime/src/closure/props.rs @@ -118,8 +118,20 @@ unsafe fn object_own_set(obj: *mut ObjectHeader, key: &str, value: f64) { /// `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 declared = crate::object::class_value::holds_declared_static_method(ptr, key); let bag = bag_ensure(ptr); object_own_set(bag, key, value); + declared_value_replaced(ptr, key, declared); +} + +/// After a write to own `key`: when it held the declaration (`declared`) and +/// no longer does, the shape transitions. +unsafe fn declared_value_replaced(ptr: usize, key: &str, declared: Option) { + let Some(old) = declared else { return }; + if bag_get(ptr, key.as_bytes()).is_some_and(|v| v.to_bits() == old.to_bits()) { + return; + } + crate::object::shapes::transition_object_shape_semantics(bag_of(ptr)); } /// [[DefineOwnProperty]] of own data property `key` with just a value: the @@ -132,9 +144,20 @@ pub(crate) unsafe fn bag_set(ptr: usize, key: &str, value: f64) { /// `ptr` is a proven, live closure cell. pub(crate) unsafe fn bag_define_value(ptr: usize, key: &str, value: f64) { let _no_move = crate::gc::GcSuppressScope::new(); + let declared = crate::object::class_value::holds_declared_static_method(ptr, key); let bag = bag_ensure(ptr); - let key = crate::string::js_string_from_bytes(key.as_ptr(), key.len() as u32); - crate::object::object_ops::define_property_force_store_value(bag, key, value); + if !bag_has_own(ptr, key.as_bytes()) { + // A NEW property — including one `delete` removed earlier: it is + // appended as any new key is, so it enumerates last. The in-place + // store below would find the deleted key's tombstoned entry and + // bring the property back at its old position. + object_own_set(bag, key, value); + declared_value_replaced(ptr, key, declared); + return; + } + let key_hdr = crate::string::js_string_from_bytes(key.as_ptr(), key.len() as u32); + crate::object::object_ops::define_property_force_store_value(bag, key_hdr, value); + declared_value_replaced(ptr, key, declared); } /// Remove the function's own data property `key`; true when it existed. @@ -147,8 +170,12 @@ pub(crate) unsafe fn bag_remove(ptr: usize, key: &str) -> bool { return false; } let _no_move = crate::gc::GcSuppressScope::new(); + let declared = crate::object::class_value::holds_declared_static_method(ptr, key); 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); + // Deleting the declaration must not let a re-add reach the shape that + // proved it (a removed last key re-added lands on the same key list). + declared_value_replaced(ptr, key, declared); true } @@ -207,6 +234,40 @@ pub(crate) unsafe fn bag_accessor_names(ptr: usize) -> Vec { out } +/// Every own property name (data AND accessor) in creation order — the +/// order of the bag's key list. A deleted key is gone from that order; a +/// key defined again after a delete is a new key and comes last. +/// +/// # Safety +/// `ptr` is a proven, live closure cell. +pub(crate) unsafe fn bag_own_key_names(ptr: usize) -> Vec { + 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 out = Vec::new(); + for i in 0..keys.count() { + if !crate::object::key_attrs::key_is_accessor_at(arr, i) + && crate::object::object_field_at_with_live(bag, i, live).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]; + if let Some(bytes) = crate::string::js_string_key_bytes(key, &mut scratch) { + out.push(String::from_utf8_lossy(bytes).into_owned()); + } + } + out +} + /// Every own data property in ECMA-262 own-key order: integer indices /// ascending, then other strings in creation order. /// diff --git a/crates/perry-runtime/src/json/stringify_tojson_probe.rs b/crates/perry-runtime/src/json/stringify_tojson_probe.rs index a20133dc85..66de736954 100644 --- a/crates/perry-runtime/src/json/stringify_tojson_probe.rs +++ b/crates/perry-runtime/src/json/stringify_tojson_probe.rs @@ -482,7 +482,6 @@ fn class_chain_may_have_to_json_uncached(class_id: u32) -> bool { /// | `Object.setPrototypeOf`, a descriptor install, or a `delete` anywhere | the SEMANTIC property epoch | /// | a prototype OBJECT materializing for this class or an ancestor — the very thing the walk looks for, since such an object can carry arbitrary later-added properties | `CLASS_LOOKUP_SURFACE_GEN`, bumped inside `class_prototype_object_root_store` and `class_decl_prototype_object_root_store` | /// | `js_register_class_generic_origin`, which redirects both prototype-object readers and `lookup_prototype_method`'s chain hop | `CLASS_LOOKUP_SURFACE_GEN` | -/// | re-exposing a `delete`d prototype key through the in-place `CLASS_DELETED_KEYS` un-mark in `class_dynamic_prop_root_store` | `CLASS_LOOKUP_SURFACE_GEN` | /// /// Garbage collection is deliberately NOT an input. The class side-table /// scanners only rewrite EXISTING slots, so no collection can add a registry diff --git a/crates/perry-runtime/src/json/stringify_tojson_probe_tests.rs b/crates/perry-runtime/src/json/stringify_tojson_probe_tests.rs index dbd5ef2d55..226db1628b 100644 --- a/crates/perry-runtime/src/json/stringify_tojson_probe_tests.rs +++ b/crates/perry-runtime/src/json/stringify_tojson_probe_tests.rs @@ -315,7 +315,7 @@ fn a_late_generic_origin_edge_retires_the_cached_chain_verdict() { } #[test] -fn un_marking_a_deleted_prototype_key_retires_the_cached_chain_verdict() { +fn a_static_store_never_resurrects_a_deleted_prototype_key() { let class_id = probe_test_class_id(0x61); crate::object::class_prototype_method_root_store( class_id, @@ -323,27 +323,22 @@ fn un_marking_a_deleted_prototype_key_retires_the_cached_chain_verdict() { probe_test_method_bits(), ); assert!(super::test_class_chain_may_have_to_json(class_id)); - crate::object::class_mark_key_deleted(class_id, "toJSON"); - // `delete C.prototype.toJSON` reaches `class_mark_key_deleted` through - // `js_object_delete_field`, which bumps the semantic epoch; stand in for - // that here so the memo holds the post-delete `false` the runtime would. + // `delete C.prototype.toJSON` removes the runtime assignment's entry + // through `js_object_delete_field`, which bumps the semantic epoch; stand + // in for that here so the memo holds the post-delete `false`. + crate::object::class_prototype_method_root_remove(class_id, "toJSON"); crate::object::prop_plan::prop_plan_epoch_bump(); assert!(!super::test_class_chain_may_have_to_json(class_id)); - // `CLASS_DELETED_KEYS` is shared between a class's prototype keys and its - // STATIC field keys (`class_registry/state.rs`), so a later `C.toJSON = 1` - // static store un-marks the key IN PLACE inside - // `class_dynamic_prop_root_store` and re-exposes the prototype method to - // `lookup_prototype_method` — with no vtable write and no descriptor - // install to move either of the other two generations. + // The static side lives on the class function object, the prototype side + // on the prototype: `C.toJSON = 1` cannot bring the prototype key back. crate::object::class_dynamic_prop_root_store( class_id, "toJSON", f64::from_bits(probe_test_method_bits()), ); assert!( - super::test_class_chain_may_have_to_json(class_id), - "un-marking a deleted key re-exposes the prototype method and must \ - retire the cached verdict" + !super::test_class_chain_may_have_to_json(class_id), + "a static store must not re-expose a deleted prototype method" ); } diff --git a/crates/perry-runtime/src/object/class_image.rs b/crates/perry-runtime/src/object/class_image.rs index 972bbc5ea9..e635bce06b 100644 --- a/crates/perry-runtime/src/object/class_image.rs +++ b/crates/perry-runtime/src/object/class_image.rs @@ -89,7 +89,8 @@ pub(crate) const PARENT_DENSE_CAP: usize = 1 << 16; /// OUTER map only takes the fast hasher (see `ClassImageTables`); the INNER /// `HashMap` stays on SipHash because its keys are JS-supplied /// member names. -pub type StaticMethodTable = PtrHashMap>; +/// (body func_ptr, param_count, has_rest, closure-convention entry or 0). +pub type StaticMethodTable = PtrHashMap>; /// class_id -> { name -> (getter func_ptr, setter func_ptr) } for static accessors. /// Outer map fast-hashed, inner `String`-keyed map deliberately not — see above. pub type StaticAccessorTable = PtrHashMap>; diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index dcbfb60604..d9b8294ca1 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -84,20 +84,20 @@ pub(crate) use state::{ builtin_parent_ctor_in_chain, class_decl_prototype_method_names, class_decl_prototype_object, class_decl_prototype_value, class_decl_prototype_value_for_instance_class, class_delete_own_dynamic_prop, class_dynamic_prop_root_store, class_has_own_dynamic_prop, - class_id_for_decl_prototype_object, class_is_key_deleted, class_mark_key_deleted, - class_object_value_for_cid, class_object_value_root_store, class_own_dynamic_prop_names, - class_own_enumerable_field_names, class_own_static_field_value, class_own_string_member_names, - class_parent_closure, class_parent_closure_root_store, class_prototype_member_names, - class_prototype_method_is_enumerable, class_prototype_method_set_enumerable, - class_prototype_method_value_cache_root_store, class_prototype_object_addr_index_contains, - class_prototype_object_addr_index_rekey, class_prototype_object_root_store, - class_ref_dynamic_prop_root_store, class_register_declared_static_global_slot, - class_static_alias_sync, class_static_clear_defined_attrs, class_static_defined_attrs, + class_id_for_decl_prototype_object, class_object_value_for_cid, class_object_value_root_store, + class_own_dynamic_prop_names, class_own_enumerable_field_names, class_own_static_field_value, + class_own_string_member_names, class_parent_closure, class_parent_closure_root_store, + class_proto_key_deleted, class_prototype_member_names, class_prototype_method_is_enumerable, + class_prototype_method_set_enumerable, class_prototype_method_value_cache_root_store, + class_prototype_object_addr_index_contains, class_prototype_object_addr_index_rekey, + class_prototype_object_root_store, class_ref_dynamic_prop_root_store, + class_register_declared_static_global_slot, class_static_alias_sync, + class_static_clear_defined_attrs, class_static_defined_attrs, class_static_key_deleted, class_static_prototype, class_static_prototype_is_nulled, class_static_prototype_root_clear, - class_static_prototype_root_store, class_static_set_defined_attrs, class_unmark_key_deleted, - decl_prototype_identity_id, global_object_prototype_bits, - is_bound_native_constructor_closure_value, is_non_constructable_builtin_function_value, - parent_closure_in_chain, throw_non_constructable_builtin_function, CLASS_OBJECT_EVER, + class_static_prototype_root_store, class_static_set_defined_attrs, decl_prototype_identity_id, + global_object_prototype_bits, is_bound_native_constructor_closure_value, + is_non_constructable_builtin_function_value, parent_closure_in_chain, + throw_non_constructable_builtin_function, CLASS_OBJECT_EVER, }; pub use state::{ AccessorDecl, ClassVTable, VTableMethodEntry, CLASS_DECL_PROTOTYPE_OBJECTS, @@ -224,12 +224,13 @@ pub(crate) use parent_static::{ class_dynamic_static_accessor_getter_value, class_has_instance_getter, class_has_own_static_method, class_has_own_symbol_member, class_has_symbol_member_in_chain, class_instance_setter_apply, class_method_bind_length, class_object_own_field_bytes, - class_object_pinned_parent, class_own_symbol_accessor_ptrs, class_own_symbol_member_keys, - class_own_symbol_method, class_private_instance_getter_value, - class_private_instance_setter_apply, class_static_accessor_getter_value, - class_static_accessor_setter_apply, class_symbol_getter_value, class_symbol_setter_apply, - dynamic_value_class_id, get_parent_class_id, lookup_class_symbol_method_in_chain, - lookup_static_method_in_chain, register_class, register_class_dynamic_static_accessor, + class_object_pinned_parent, class_own_static_method_code, class_own_static_method_entry, + class_own_symbol_accessor_ptrs, class_own_symbol_member_keys, class_own_symbol_method, + class_private_instance_getter_value, class_private_instance_setter_apply, + class_static_accessor_getter_value, class_static_accessor_setter_apply, + class_symbol_getter_value, class_symbol_setter_apply, dynamic_value_class_id, + get_parent_class_id, lookup_class_symbol_method_in_chain, lookup_static_method_in_chain, + lookup_static_method_owner, register_class, register_class_dynamic_static_accessor, static_accessor_in_chain, }; pub use parent_static::{ diff --git a/crates/perry-runtime/src/object/class_registry/construct.rs b/crates/perry-runtime/src/object/class_registry/construct.rs index 383672d44c..d70bc352ab 100644 --- a/crates/perry-runtime/src/object/class_registry/construct.rs +++ b/crates/perry-runtime/src/object/class_registry/construct.rs @@ -1949,9 +1949,6 @@ pub(super) fn is_arrow_function_value(value: f64) -> bool { /// parent-class chain so methods registered on a base class are found /// via subclass instances. pub(crate) fn lookup_own_prototype_method(class_id: u32, name: &str) -> Option { - if class_is_key_deleted(class_id, name) { - return None; - } CLASS_PROTOTYPE_METHODS.with(|table| { let guard = table.read().ok()?; let bits = guard.as_ref()?.get(&class_id)?.get(name)?; @@ -1966,7 +1963,7 @@ pub(crate) fn lookup_prototype_method(class_id: u32, name: &str) -> Option let mut cid = class_id; let mut depth = 0usize; while depth < 32 { - if !class_is_key_deleted(cid, name) { + if !class_proto_key_deleted(cid, name) { if let Some(per_class) = map.get(&cid) { if let Some(&bits) = per_class.get(name) { return Some(f64::from_bits(bits)); diff --git a/crates/perry-runtime/src/object/class_registry/decl_accessors.rs b/crates/perry-runtime/src/object/class_registry/decl_accessors.rs index b126b10078..f745277544 100644 --- a/crates/perry-runtime/src/object/class_registry/decl_accessors.rs +++ b/crates/perry-runtime/src/object/class_registry/decl_accessors.rs @@ -82,7 +82,7 @@ pub(crate) fn note_instance_accessor_registered(class_id: u32, name: &str) { return; } let proto = class_decl_prototype_object(class_id); - if !proto.is_null() && !class_is_key_deleted(class_id, name) { + if !proto.is_null() && !class_proto_key_deleted(class_id, name) { install_decl_prototype_accessor(proto, class_id, name); } } @@ -101,8 +101,8 @@ pub(crate) fn decl_prototype_own_accessor(class_id: u32, name: &str) -> Option(); - let declared = - !class_is_key_deleted(class_id, name) && class_own_accessor_ptrs(class_id, name).is_some(); + let declared = !class_proto_key_deleted(class_id, name) + && class_own_accessor_ptrs(class_id, name).is_some(); // SAFETY: `obj` is the live decl prototype; nothing below allocates. let holds = declared || unsafe { diff --git a/crates/perry-runtime/src/object/class_registry/dispatch.rs b/crates/perry-runtime/src/object/class_registry/dispatch.rs index 6317156ac0..fd5c637957 100644 --- a/crates/perry-runtime/src/object/class_registry/dispatch.rs +++ b/crates/perry-runtime/src/object/class_registry/dispatch.rs @@ -59,10 +59,8 @@ pub(crate) fn test_bump_vtable_generation() { /// `Object.getPrototypeOf(instance)`, a `super` chain); /// * `js_register_class_generic_origin` — redirects BOTH prototype-object /// readers and `lookup_prototype_method`'s chain hop to another class id; -/// * the in-place `CLASS_DELETED_KEYS` un-mark inside -/// `class_dynamic_prop_root_store` — re-exposes a `delete`d prototype key. /// -/// Bumped INSIDE those four writers, after the store, so a new call site +/// Bumped INSIDE those three writers, after the store, so a new call site /// cannot forget it — the same enforced-funnel rule `prop_plan_epoch_bump` /// follows. Kept separate from `VTABLE_GEN` precisely so that a consumer of /// this counter does not impose the dispatch-speculation cost that bumping @@ -97,7 +95,7 @@ pub(crate) fn class_lookup_surface_gen_bump() { // and recorded prototype bits all unchanged and the old prototype object // unmutated, so nothing else in that entry's guard can see it — and the // entry would then answer with a different object than the chain walk - // beside it. All four callers are registry stores on cold paths. + // beside it. All three callers are registry stores on cold paths. crate::object::proto_validity::bump_proto_validity(); } diff --git a/crates/perry-runtime/src/object/class_registry/gc_roots.rs b/crates/perry-runtime/src/object/class_registry/gc_roots.rs index eff9940f46..0033089974 100644 --- a/crates/perry-runtime/src/object/class_registry/gc_roots.rs +++ b/crates/perry-runtime/src/object/class_registry/gc_roots.rs @@ -586,11 +586,7 @@ fn visit_metadata_nanbox_key( #[cfg(test)] pub(crate) fn test_clear_class_side_table_roots() { - // Disambiguate: CLASS_DELETED_KEYS is reachable via both `use super::*` - // and `use crate::object::*`; name the canonical definition explicitly. - use super::state::CLASS_DELETED_KEYS; super::state::CLASS_DECLARED_STATIC_GLOBAL_SLOTS.with(|m| m.borrow_mut().clear()); - CLASS_DELETED_KEYS.with(|m| m.borrow_mut().clear()); CLASS_PROTOTYPE_METHOD_VALUES.with(|cache| cache.borrow_mut().clear()); CLASS_PROTOTYPE_METHODS.with(|table| { if let Ok(mut guard) = table.write() { diff --git a/crates/perry-runtime/src/object/class_registry/parent_static.rs b/crates/perry-runtime/src/object/class_registry/parent_static.rs index 1d6bab592b..54a93dd451 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -622,14 +622,48 @@ pub unsafe extern "C" fn js_register_class_static_method( .unwrap() .entry(class_id as u32) .or_default() - .insert( - name.clone(), - (func_ptr as usize, param_count as u32, has_rest != 0), - ); + .entry(name.clone()) + .and_modify(|e| { + (e.0, e.1, e.2) = (func_ptr as usize, param_count as u32, has_rest != 0) + }) + .or_insert((func_ptr as usize, param_count as u32, has_rest != 0, 0)); } crate::object::class_value::note_intrinsic_registration(class_id as u32, &name); } +/// Record the closure-convention entry `__clo(closure, args...)` +/// codegen emitted for ClassBody static method `name` of class `class_id`: +/// the code of the method's own function object (one per class and method). +/// Its arity, length, name and source were registered on the code itself. +/// Emitted at module init after `js_register_class_static_method`. +#[no_mangle] +pub unsafe extern "C" fn js_register_class_static_method_entry( + class_id: i64, + name_ptr: *const u8, + name_len: i64, + entry: i64, +) { + if class_id == 0 || name_ptr.is_null() || name_len <= 0 || entry == 0 { + return; + } + let Ok(name) = std::str::from_utf8(std::slice::from_raw_parts(name_ptr, name_len as usize)) + else { + return; + }; + { + let mut guard = CLASS_STATIC_METHODS.write().unwrap(); + let Some(record) = guard + .as_mut() + .and_then(|all| all.get_mut(&(class_id as u32))) + .and_then(|m| m.get_mut(name)) + else { + return; + }; + record.3 = entry as usize; + } + crate::object::class_value::note_intrinsic_registration(class_id as u32, name); +} + fn property_key_string(key: f64) -> Option { let property_key = unsafe { crate::object::js_to_property_key(key) }; if unsafe { crate::symbol::js_is_symbol(property_key) } != 0 { @@ -647,7 +681,13 @@ fn property_key_string(key: f64) -> Option { } } +/// Register a computed-key ClassBody method when the class definition +/// evaluates its key. A static one with a string key is a ClassBody static +/// method like any other: `entry` is its closure-convention entry +/// (`__clo`), the code of its own function object, whose `name` is the +/// key. #[no_mangle] +#[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn js_register_class_computed_method( class_id: i64, key: f64, @@ -656,6 +696,7 @@ pub unsafe extern "C" fn js_register_class_computed_method( is_static: i64, has_rest: i64, definition_order: i64, + entry: i64, ) { if class_id == 0 || func_ptr == 0 { return; @@ -759,9 +800,33 @@ pub unsafe extern "C" fn js_register_class_computed_method( if guard.is_none() { *guard = Some(crate::fast_hash::new_ptr_hash_map()); } - guard.as_mut().unwrap().entry(class_id).or_default().insert( - name.clone(), - (func_ptr as usize, param_count as u32, has_rest != 0), + guard + .as_mut() + .unwrap() + .entry(class_id) + .or_default() + .entry(name.clone()) + .and_modify(|e| { + (e.0, e.1, e.2, e.3) = ( + func_ptr as usize, + param_count as u32, + has_rest != 0, + entry as usize, + ) + }) + .or_insert(( + func_ptr as usize, + param_count as u32, + has_rest != 0, + entry as usize, + )); + } + if entry != 0 { + // SetFunctionName(F, key): the key is known only now. + crate::builtins::js_register_function_name( + entry as *const u8, + name.as_ptr(), + name.len() as u32, ); } crate::object::class_value::note_intrinsic_registration(class_id, &name); @@ -900,19 +965,59 @@ pub(crate) fn class_has_own_static_method(class_id: u32, name: &str) -> bool { .unwrap_or(false) } -pub(crate) fn lookup_static_method_in_chain( +/// ClassBody static method `name` declared by class `class_id` itself: +/// `(func_ptr, param_count, has_rest)`. +pub(crate) fn class_own_static_method_entry( class_id: u32, name: &str, ) -> Option<(usize, u32, bool)> { let guard = CLASS_STATIC_METHODS.read().ok()?; - let map = guard.as_ref()?; + let e = guard.as_ref()?.get(&class_id)?.get(name).copied()?; + Some((e.0, e.1, e.2)) +} + +/// The closure-convention entry of ClassBody static method `name` declared by +/// class `class_id` itself: the code of its own function object. +pub(crate) fn class_own_static_method_code(class_id: u32, name: &str) -> Option { + let guard = CLASS_STATIC_METHODS.read().ok()?; + let e = guard.as_ref()?.get(&class_id)?.get(name).copied()?; + (e.3 != 0).then_some(e.3) +} + +/// The static method `name` a call on class `class_id` runs: the nearest +/// declaration whose own property on its class's function object is still +/// that declaration. A deleted one is skipped (the parent's applies); a +/// redefined one ends the lookup (the property's value is what runs). +pub(crate) fn lookup_static_method_in_chain( + class_id: u32, + name: &str, +) -> Option<(usize, u32, bool)> { + lookup_static_method_owner(class_id, name).map(|(_, e)| e) +} + +/// [`lookup_static_method_in_chain`] plus the class whose declaration runs. +/// The walk reads the class function objects: a class whose object owns +/// `name` (declared, assigned, or deleted and reassigned) ends it — its +/// declaration when the property still is that declaration's function, +/// otherwise nothing (the property's value is what a call runs). +pub(crate) fn lookup_static_method_owner( + class_id: u32, + name: &str, +) -> Option<(u32, (usize, u32, bool))> { + use crate::object::class_value::StaticMethodProperty; let mut cid = class_id; let mut depth = 0usize; while cid != 0 && depth < 32 { - if let Some(m) = map.get(&cid) { - if let Some(&entry) = m.get(name) { - return Some(entry); + let entry = { + let guard = CLASS_STATIC_METHODS.read().ok()?; + guard.as_ref()?.get(&cid).and_then(|m| m.get(name)).copied() + }; + match crate::object::class_value::static_method_property(cid, name, entry.map(|e| e.3)) { + StaticMethodProperty::Live => { + return entry.map(|e| (cid, (e.0, e.1, e.2))); } + StaticMethodProperty::Replaced => return None, + StaticMethodProperty::Deleted => {} } match get_parent_class_id(cid) { Some(p) if p != 0 && p != cid => { @@ -1791,19 +1896,33 @@ pub(crate) use crate::object::class_meta_registry::get_parent_class_id; /// if found, `None` otherwise. /// Used by `js_assimilate_thenable` (refs #586) and other runtime callers /// that need to probe a class for a method without invoking it. +/// +/// A declared method removed from its class's materialized prototype object +/// (`delete C.prototype.m`) is not provided by that class: the prototype +/// object's own keys are the truth, the vtable entry only names the body. +/// The walk then continues to the parent, as the JS prototype chain does. pub fn lookup_class_method_in_chain(class_id: u32, name: &str) -> Option<(usize, u32, bool, bool)> { - let registry = CLASS_VTABLE_REGISTRY.read().unwrap(); - let reg = registry.as_ref()?; let mut cur = class_id; for _ in 0..32 { - if let Some(vt) = reg.get(&cur) { - if let Some(entry) = vt.methods.get(name) { - return Some(( - entry.func_ptr, - entry.param_count, - entry.has_synthetic_arguments, - entry.has_rest, - )); + let found = { + let registry = CLASS_VTABLE_REGISTRY.read().unwrap(); + let reg = registry.as_ref()?; + reg.get(&cur) + .and_then(|vt| vt.methods.get(name)) + .map(|entry| { + ( + entry.func_ptr, + entry.param_count, + entry.has_synthetic_arguments, + entry.has_rest, + ) + }) + }; + if let Some(entry) = found { + // Checked with the registry lock released: the deletedness probe + // reads the class tables again. + if !super::class_proto_key_deleted(cur, name) { + return Some(entry); } } match get_parent_class_id(cur) { diff --git a/crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs b/crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs index 3af1e17e3a..5850ee93bd 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs @@ -319,7 +319,7 @@ pub(crate) unsafe fn call_private_static_method_for_owner( args_ptr: *const f64, args_len: usize, ) -> Option { - let (func_ptr, param_count, has_rest) = CLASS_STATIC_METHODS + let (func_ptr, param_count, has_rest, _) = CLASS_STATIC_METHODS .read() .ok()? .as_ref()? diff --git a/crates/perry-runtime/src/object/class_registry/prototype_methods.rs b/crates/perry-runtime/src/object/class_registry/prototype_methods.rs index 3e62dd6f8c..e808ddf10a 100644 --- a/crates/perry-runtime/src/object/class_registry/prototype_methods.rs +++ b/crates/perry-runtime/src/object/class_registry/prototype_methods.rs @@ -211,9 +211,6 @@ pub(crate) fn invalidate_class_prototype_fast_guards() { } pub(crate) fn class_prototype_method_root_store(class_id: u32, name: String, value_bits: u64) { - // Assignment / defineProperty after `delete C.prototype.m` recreates the - // own property and must make it visible to dispatch again. - class_unmark_key_deleted(class_id, &name); CLASS_PROTOTYPE_METHODS.with(|table| { let mut guard = table.write().unwrap(); if guard.is_none() { diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index 8e5d1bac47..f48a3219f2 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -7,8 +7,6 @@ use std::collections::HashMap; use std::sync::RwLock; crate::perry_thread_local! { - pub(crate) static CLASS_DELETED_KEYS: std::cell::RefCell>> = - std::cell::RefCell::new(std::collections::HashMap::new()); /// Backing LLVM globals for declared static fields, keyed exactly like /// `CLASS_DYNAMIC_PROPS`. Direct compiled reads use these cells, while /// computed/member writes reach the runtime side table. Remembering the @@ -42,33 +40,62 @@ pub(crate) fn throw_non_constructable_builtin_function() -> ! { super::super::object_ops::throw_object_type_error(b"Function is not a constructor") } -pub(crate) fn class_mark_key_deleted(class_id: u32, key: &str) { - if class_id == 0 { - return; +/// Has `delete` removed class `class_id`'s own ClassBody prototype member +/// `name` (a method, an accessor, or `constructor`)? Derived from the object +/// that owns the member: it was declared, the class's decl prototype exists, +/// and neither that object nor a runtime prototype assignment holds the key. +/// Every delete of a prototype member retires the per-name prototype fast +/// guard first, so a name whose guard is intact was never deleted anywhere. +pub(crate) fn class_proto_key_deleted(class_id: u32, name: &str) -> bool { + if class_id == 0 + || !class_prototype_fast_guard_invalidated_for_method(class_prototype_method_guard_slot( + name, + )) + { + return false; } - CLASS_DELETED_KEYS.with(|m| { - m.borrow_mut() - .entry(class_id) - .or_default() - .insert(key.to_string()); - }); -} - -pub(crate) fn class_is_key_deleted(class_id: u32, key: &str) -> bool { - CLASS_DELETED_KEYS.with(|m| { - m.borrow() - .get(&class_id) - .map(|keys| keys.contains(key)) + let declared = name == "constructor" + || class_own_accessor_ptrs(class_id, name).is_some() + || super::super::native_module::class_has_own_method(class_id, name); + if !declared { + return false; + } + let proto = class_decl_prototype_object(class_id); + if proto.is_null() { + // Never materialized: nothing was deleted from it. + return false; + } + let assigned = CLASS_PROTOTYPE_METHODS.with(|table| { + table + .read() + .ok() + .and_then(|g| { + g.as_ref() + .map(|m| m.get(&class_id).is_some_and(|p| p.contains_key(name))) + }) .unwrap_or(false) - }) + }); + // SAFETY: `proto` is this realm's live decl prototype; nothing below + // allocates. + !assigned + && !unsafe { + let keys = crate::object::object_keys(proto); + let arr = keys.arr(); + !arr.is_null() + && crate::object::keys_find_slot_by_bytes_resolved( + arr, + keys.count(), + name.as_bytes(), + ) + .is_some() + } } -pub(crate) fn class_unmark_key_deleted(class_id: u32, key: &str) { - CLASS_DELETED_KEYS.with(|m| { - if let Some(keys) = m.borrow_mut().get_mut(&class_id) { - keys.remove(key); - } - }); +/// Has `delete` removed class `class_id`'s own static member `name` (a +/// ClassBody static method or accessor, or the intrinsic `name` / `length`)? +/// Derived from the class function object that owns it. +pub(crate) fn class_static_key_deleted(class_id: u32, name: &str) -> bool { + crate::object::class_value::class_static_key_deleted(class_id, name) } /// Record `C. = value` in the class-ref side table that dynamic reads @@ -83,26 +110,10 @@ pub(crate) fn class_unmark_key_deleted(class_id: u32, key: &str) { /// constructor runs it once per construction (144,000 times in /// gc-handoff/apps/shapes.ts) and the key exists after the first. /// -/// The in-place update also skips the `CLASS_DELETED_KEYS` probe — but only -/// when NO class key has ever been deleted, which is the state of essentially -/// every program (`delete C.x` on a class constructor is vanishingly rare). -/// Once anything has been deleted the original sequence runs verbatim, so the -/// interaction between a deleted PROTOTYPE key and a same-named static field -/// (`class C { m() {} static m = 1 }` — both land under one class_id) keeps -/// whatever behaviour it had. +/// A static store touches only the class function object: the prototype +/// side lives on the prototype object, so `C.m = 1` can never resurrect a +/// deleted `C.prototype.m`. pub(crate) fn class_dynamic_prop_root_store(class_id: u32, name: &str, value: f64) { - // Un-marking re-exposes a previously `delete`d prototype key to - // `class_instance_has_member` / `lookup_prototype_method` — the one - // direction a cached "this chain resolves nothing" verdict must not - // survive (#10696). - let was_deleted = CLASS_DELETED_KEYS.with(|m| { - m.borrow_mut() - .get_mut(&class_id) - .is_some_and(|keys| keys.remove(name)) - }); - if was_deleted { - super::class_lookup_surface_gen_bump(); - } // The class function object's own-property bag (barriered, traced). crate::object::class_value::class_static_set(class_id, name, value); class_static_alias_sync(class_id, name); @@ -125,8 +136,7 @@ pub(crate) fn class_static_alias_sync(class_id: u32, name: &str) { }) else { return; }; - let plain = !class_is_key_deleted(class_id, name) - && class_static_defined_attrs(class_id, name).is_none_or(|(writable, _, _)| writable) + let plain = class_static_defined_attrs(class_id, name).is_none_or(|(writable, _, _)| writable) && !crate::object::class_value::class_static_has_own_accessor(class_id, name); let value = plain .then(|| crate::object::class_value::class_static_get(class_id, name)) @@ -1424,28 +1434,18 @@ mod class_dynamic_prop_store_tests { assert_eq!(keys, vec!["made".to_string(), "other".to_string()]); } - /// The fast path is gated on "nothing has ever been deleted". Once a key - /// IS deleted, a re-store must still clear it from the deleted set — the - /// behaviour the unconditional probe used to provide. + /// `delete C.k` removes the key from the class function object; a later + /// store defines it again. #[test] - fn store_after_delete_clears_the_deleted_mark() { + fn store_after_delete_defines_the_key_again() { let cid = 0x7c01_0002; class_dynamic_prop_root_store(cid, "k", 1.0); - // Delete the way `delete C.k` does: drop the value AND mark the key. class_delete_own_dynamic_prop(cid, "k"); - class_mark_key_deleted(cid, "k"); - assert!(class_is_key_deleted(cid, "k")); assert_eq!(stored(cid, "k"), None); class_dynamic_prop_root_store(cid, "k", 2.0); - assert!( - !class_is_key_deleted(cid, "k"), - "re-storing a deleted static key must un-delete it" - ); assert_eq!(stored(cid, "k"), Some(2.0)); - // And a subsequent store, now on the slow arm (the deleted-keys map - // is non-empty for the whole process), still updates the value. class_dynamic_prop_root_store(cid, "k", 3.0); assert_eq!(stored(cid, "k"), Some(3.0)); } diff --git a/crates/perry-runtime/src/object/class_value.rs b/crates/perry-runtime/src/object/class_value.rs index 4968502935..9549bfa0f9 100644 --- a/crates/perry-runtime/src/object/class_value.rs +++ b/crates/perry-runtime/src/object/class_value.rs @@ -261,9 +261,25 @@ fn class_value_mint(class_id: u32) -> *mut ClosureHeader { for key in INTRINSIC_OWN_DATA_KEYS { install_intrinsic_own_data(class_id, key); } - // ClassBody static accessors, in ClassBody order. + // MakeConstructor: `prototype` { !w, !e, !c } is created with the class, + // after `length` and `name` and before every ClassBody static, so the own + // key order is the bag's creation order. + let proto = super::class_registry::class_decl_prototype_value(class_id); + if crate::value::JSValue::from_bits(proto.to_bits()).is_pointer() { + // SAFETY: `ptr` is the class closure minted above. + unsafe { crate::closure::props::bag_define_value(ptr as usize, "prototype", proto) }; + super::class_registry::class_static_set_defined_attrs( + class_id, + "prototype", + false, + false, + false, + ); + } + // ClassBody static methods and accessors, in ClassBody order. for key in super::class_registry::class_own_string_member_names(class_id, true) { install_declared_static_accessor(class_id, &key); + install_declared_static_method(class_id, &key); } ptr } @@ -353,9 +369,13 @@ pub(crate) fn note_intrinsic_registration(class_id: u32, key: &str) { if super::class_registry::class_registered_static_accessor_ptrs(class_id, key).is_some() { install_declared_static_accessor(class_id, key); } - if !INTRINSIC_OWN_DATA_KEYS.contains(&key) { - return; + if INTRINSIC_OWN_DATA_KEYS.contains(&key) { + note_intrinsic_key_registration(class_id, key); } + install_declared_static_method(class_id, key); +} + +fn note_intrinsic_key_registration(class_id: u32, key: &str) { if holds_intrinsic(class_id, key) { if static_member_owns(class_id, key) { class_static_remove(class_id, key); @@ -364,12 +384,358 @@ pub(crate) fn note_intrinsic_registration(class_id: u32, key: &str) { class_static_set(class_id, key, value); } } else if class_static_get(class_id, key).is_none() - && !super::class_registry::class_is_key_deleted(class_id, key) + // SAFETY: minted above (`class_value_cached`). + && !unsafe { crate::closure::props::state_is_deleted(class_value_ptr(class_id) as usize, key) } { install_intrinsic_own_data(class_id, key); } } +/// `delete C.` removed own `name` of class `class_id`: an intrinsic +/// `name` / `length` is remembered on the object (as for any function, #3655), +/// so a later registration does not install it again. +pub(crate) fn note_static_key_deleted(class_id: u32, name: &str) { + if INTRINSIC_OWN_DATA_KEYS.contains(&name) { + // SAFETY: this agent's live class closure. + unsafe { + crate::closure::props::state_mark_deleted(class_value_ptr(class_id) as usize, name) + }; + } +} + +/// Has `delete` removed class `class_id`'s own static member `name` — a +/// ClassBody static method or accessor, or the intrinsic `name` / `length`? +/// Derived from the function object: the member was declared and the object +/// no longer has the key. A never-minted object has deleted nothing. +pub(crate) fn class_static_key_deleted(class_id: u32, name: &str) -> bool { + if name.starts_with('#') || is_internal_static_key(name) { + return false; + } + let Some(ptr) = class_value_if_minted(class_id) else { + return false; + }; + let declared = static_member_owns(class_id, name) + || (INTRINSIC_OWN_DATA_KEYS.contains(&name) + && intrinsic_own_data_registered(class_id, name)); + // SAFETY: this agent's live class closure; the lookup does not allocate. + declared && !unsafe { crate::closure::props::bag_has_own(ptr as usize, name.as_bytes()) } +} + +fn intrinsic_own_data_registered(class_id: u32, key: &str) -> bool { + match key { + "length" => super::class_registry::class_length_for_id(class_id).is_some(), + "name" => super::class_registry::class_name_for_id(class_id).is_some(), + _ => false, + } +} + +/// What own property `name` of class `class_id`'s function object says about +/// the ClassBody static method `name` whose code is `func_ptr`. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum StaticMethodProperty { + /// The property is still the declaration's function: dispatch it. + Live, + /// `delete` removed it: the lookup continues at the parent class. + Deleted, + /// The program redefined it: the property's value wins. + Replaced, +} + +/// Class `class_id`'s answer for static method `name` during a chain walk. +/// `declared` is its own ClassBody declaration of `name` (the declaration's +/// closure-convention entry, 0 when it has none), `None` when it declares no +/// such method. +pub(crate) fn static_method_property( + class_id: u32, + name: &str, + declared: Option, +) -> StaticMethodProperty { + let live_or_next = if declared.is_some() { + StaticMethodProperty::Live + } else { + StaticMethodProperty::Deleted + }; + if name.starts_with('#') || is_internal_static_key(name) { + return live_or_next; + } + // A never-minted object owns exactly its declarations. + let Some(ptr) = class_value_if_minted(class_id) else { + return live_or_next; + }; + let code = declared.unwrap_or(0); + // SAFETY: this agent's live class closure; nothing below allocates. + unsafe { + match crate::closure::props::bag_get(ptr as usize, name.as_bytes()) { + Some(v) if code != 0 && static_method_code_of(v) == Some(code) => { + StaticMethodProperty::Live + } + Some(_) => StaticMethodProperty::Replaced, + None if crate::closure::props::bag_has_own(ptr as usize, name.as_bytes()) => { + StaticMethodProperty::Replaced + } + // A declaration without an entry is never installed as a property. + None if declared == Some(0) => StaticMethodProperty::Live, + None => StaticMethodProperty::Deleted, + } + } +} + +/// The code of `value` when it is a function object (a closure). +unsafe fn static_method_code_of(value: f64) -> Option { + let js = crate::JSValue::from_bits(value.to_bits()); + if !js.is_pointer() { + return None; + } + let f = js.as_pointer::(); + if !crate::closure::is_closure_ptr(f as usize) { + return None; + } + Some((*f).func_ptr as usize) +} + +/// The class whose ClassBody static method `name` a read of `name` on class +/// `class_id` finds, when the property found is still that declaration's +/// function object: its value, minting the owner's function object (so every +/// subclass reads the one own property, `Q.a === P.a`). +pub(crate) fn inherited_static_method_value(class_id: u32, name: &str) -> Option { + let (owner, _) = super::class_registry::lookup_static_method_owner(class_id, name)?; + super::class_registry::class_own_static_method_code(owner, name)?; + class_value(owner); + class_static_get(owner, name) +} + +/// Does own `key` of the class function object `ptr` hold its ClassBody +/// static method's function object (the declaration's code)? That fact is +/// part of the object's shape: a write that ends it transitions the shape +/// (`object::class_value::js_class_static_call_guard`). +pub(crate) unsafe fn holds_declared_static_method(ptr: usize, key: &str) -> Option { + let cid = class_closure_id_unchecked(ptr as *const ClosureHeader)?; + let code = super::class_registry::class_own_static_method_code(cid, key)?; + let v = crate::closure::props::bag_get(ptr, key.as_bytes())?; + let js = crate::JSValue::from_bits(v.to_bits()); + if !js.is_pointer() { + return None; + } + let f = js.as_pointer::(); + (crate::closure::is_closure_ptr(f as usize) && (*f).func_ptr as usize == code).then_some(v) +} + +/// [`js_class_static_call_guard`] for a call whose receiver is a value +/// (`(C as any).m()`, a local holding the class): the receiver must be its +/// class's function object on this agent, or the call reads the property. +/// +/// # Safety +/// As [`js_class_static_call_guard`]. +#[no_mangle] +pub unsafe extern "C" fn js_class_static_value_call_guard( + receiver: f64, + owner_id: i32, + name_ptr: *const u8, + name_len: i64, + body: i64, + memo: *mut u64, +) -> i32 { + let bits = receiver.to_bits(); + let Some(cid) = class_value_id_bits(bits) else { + return 0; + }; + if legacy_class_value_word(bits).is_none() { + let js = crate::JSValue::from_bits(bits); + if !js.is_pointer() + || class_value_cached(cid).map(|c| c as usize) != Some(js.as_pointer::() as usize) + { + return 0; + } + } + js_class_static_call_guard(cid as i32, owner_id, name_ptr, name_len, body, memo) +} + +/// The shape code of class `class_id`'s function object for a static-call +/// memo: 0 when this agent never minted it (it owns exactly its +/// declarations), `u32::MAX - 1` when it has no own-property bag, else the +/// bag's ShapeId. +fn static_call_shape_code(class_id: u32) -> u32 { + match class_value_cached(class_id) { + None => 0, + // SAFETY: this agent's live class closure; a shape load. + Some(c) => unsafe { + let bag = crate::closure::props::bag_of(c as usize); + if bag.is_null() { + u32::MAX - 1 + } else { + super::shapes::object_shape_stamp(bag) + } + }, + } +} + +/// Codegen's direct static call `C.m(..)` runs the declared body `body` only +/// while the property the call reads — own `m` of C, or of the class `owner` +/// it inherits from — is still that declaration's function object. The fact +/// lives in the class function objects' shapes: installing a declaration, +/// storing a different value over it, and deleting it each transition the +/// shape (`closure::props`), so an unchanged pair of shapes proves it. +/// +/// `memo` is the site's [`StaticCallMemo`]. The site's hit is inline in +/// compiled code: it loads the two function objects' shape words through the +/// memo's pointers and compares them with `key`; only a miss calls this, +/// which re-validates by reading the property and re-arms the memo (only for +/// a one-link chain, whose two shapes cover every object the read consults). +/// +/// # Safety +/// `name_ptr` points at `name_len` bytes; `memo` is null or the site's +/// [`StaticCallMemo`] (thread-local when the program starts workers, so the +/// function objects it names are this agent's). +#[no_mangle] +pub unsafe extern "C" fn js_class_static_call_guard( + class_id: i32, + owner_id: i32, + name_ptr: *const u8, + name_len: i64, + body: i64, + memo: *mut u64, +) -> i32 { + let cid = class_id as u32; + let owner = owner_id as u32; + // SAFETY: null or the site's memo (see `# Safety`). + let memo = (!memo.is_null()).then(|| &mut *(memo as *mut StaticCallMemo)); + if name_ptr.is_null() || name_len <= 0 { + return 1; + } + let Ok(name) = std::str::from_utf8(std::slice::from_raw_parts(name_ptr, name_len as usize)) + else { + return 1; + }; + match super::class_registry::lookup_static_method_owner(cid, name) { + Some((found, (func_ptr, ..))) if func_ptr == body as usize => { + let one_link = + found == cid || super::class_registry::get_parent_class_id(cid) == Some(found); + if let Some(m) = memo { + if owner != 0 && found == owner && one_link { + arm_static_call_memo(m, cid, owner); + } + } + 1 + } + _ => 0, + } +} + +/// A static-call site's memo (`perry-codegen/src/expr/static_method.rs`), +/// four words the emitted hit reads (`perry_abi::STATIC_CALL_MEMO_*`): +/// `key` = (C's shape word | owner's shape word << 32) of the last +/// validation, and the two class function objects whose shape words the hit +/// loads (pinned for the agent's life, so the pointers never go stale), plus +/// C's function object as a value, for a site whose receiver is a value. +/// `c == 0` means never armed: the hit tests it before any load. +#[repr(C)] +pub struct StaticCallMemo { + pub key: u64, + pub c: usize, + pub owner: usize, + pub c_value: u64, +} + +const _: () = { + assert!( + std::mem::offset_of!(StaticCallMemo, key) + == crate::codegen_abi::STATIC_CALL_MEMO_KEY_OFFSET + ); + #[cfg(target_pointer_width = "64")] + { + assert!( + std::mem::offset_of!(StaticCallMemo, c) + == crate::codegen_abi::STATIC_CALL_MEMO_C_OFFSET + ); + assert!( + std::mem::offset_of!(StaticCallMemo, owner) + == crate::codegen_abi::STATIC_CALL_MEMO_OWNER_OFFSET + ); + assert!( + std::mem::offset_of!(StaticCallMemo, c_value) + == crate::codegen_abi::STATIC_CALL_MEMO_VALUE_OFFSET + ); + } +}; + +/// Arm `memo` for the one-link chain (`cid`, `owner`) just validated. The +/// inline hit compares raw shape words, so it is armed only when both are +/// ShapeIds (a raw word equals a ShapeId only when it is that ShapeId); the +/// function objects are minted here (an unobservable act: a fresh object owns +/// exactly its declarations), so a class used only through static calls +/// still gets the inline hit. +fn arm_static_call_memo(memo: &mut StaticCallMemo, cid: u32, owner: u32) { + let c = class_value_ptr(cid); + let o = class_value_ptr(owner); + let (sc, so) = (static_call_shape_code(cid), static_call_shape_code(owner)); + if !super::shapes::is_shape_id(sc) || !super::shapes::is_shape_id(so) { + return; + } + // GC_STORE_AUDIT(ROOT): a compiled site's memo naming PINNED class + // function objects (never move), also rooted by the class-value table. + memo.c = c as usize; + memo.owner = o as usize; + memo.c_value = crate::value::POINTER_TAG | (c as u64); + memo.key = u64::from(sc) | u64::from(so) << 32; +} + +/// Does class `class_id`'s function object own static method `name` as a data +/// property it has not deleted (the declaration, or a value put in its place)? +pub(crate) fn class_static_owns_method(class_id: u32, name: &str) -> bool { + if class_static_get(class_id, name).is_some() { + return true; + } + super::class_registry::class_own_static_method_entry(class_id, name).is_some() + && class_value_if_minted(class_id).is_none_or(|_| { + super::class_registry::class_own_static_method_code(class_id, name).is_none() + }) +} + +/// ClassDefinitionEvaluation's static methods: ClassBody static method +/// `name` of class `class_id` is an own data property of its function object, +/// `{ writable: true, enumerable: false, configurable: true }`, whose value is +/// one function object per (class, method) running exactly this +/// declaration's code (a resolved entry, never a by-name dispatch). A +/// property the program redefined is left alone; a re-registered entry +/// (a class expression evaluated again) refreshes the function. +fn install_declared_static_method(class_id: u32, name: &str) { + if name.starts_with('#') || is_internal_static_key(name) { + return; + } + let Some(code) = super::class_registry::class_own_static_method_code(class_id, name) else { + return; + }; + let ptr = class_value_ptr(class_id) as usize; + // SAFETY: this agent's live class closure. + unsafe { + match crate::closure::props::bag_get(ptr, name.as_bytes()) { + // Already this declaration's function, or a value the program + // put in its place: leave it. + Some(_) => return, + None if crate::closure::props::bag_has_own(ptr, name.as_bytes()) + || crate::closure::props::state_is_deleted(ptr, name) => + { + return + } + None => {} + } + } + let _no_collect = crate::gc::GcSuppressScope::new(); + let f = crate::closure::js_closure_alloc(code as *const u8, 0); + if f.is_null() { + return; + } + class_static_set(class_id, name, crate::value::js_nanbox_pointer(f as i64)); + super::class_registry::class_static_set_defined_attrs(class_id, name, true, false, true); + // The object's shape now carries "own `name` is this declaration": a + // process-unique successor, so no object that got `name` any other way + // (and no other agent's object) shares it (`js_class_static_call_guard`). + // SAFETY: this agent's live class closure; the bag exists (just stored). + unsafe { + super::shapes::transition_object_shape_semantics(crate::closure::props::bag_of(ptr)); + } +} + /// The class function object for `class_id` if this agent has minted it. /// A read that finds none has its answer without minting one: an object that /// was never created owns no properties. (A builtin parent such as `Error` @@ -1098,10 +1464,30 @@ mod tests { 0, ) }; + extern "C" fn static_name_entry(_closure: i64) -> f64 { + 0.0 + } + unsafe { + crate::object::class_registry::parent_static::js_register_class_static_method_entry( + cid as i64, + b"name".as_ptr(), + 4, + static_name_entry as *const () as usize as i64, + ) + }; + // node: `class Zed { static name() {} }` -> Zed.name is the method, an + // own data property { writable, !enumerable, configurable }. + let method = unsafe { crate::closure::props::bag_get(ptr, b"name") } + .expect("a static method named `name` is the class's own `name`"); assert_eq!( - unsafe { crate::closure::props::bag_get(ptr, b"name") }, - None, - "a static method named `name` is the class's own `name`" + unsafe { static_method_code_of(method) }, + Some(static_name_entry as *const () as usize), + "its value is the method's function object" + ); + assert_eq!( + crate::object::class_registry::class_static_defined_attrs(cid, "name"), + Some((true, false, true)), + "method attributes, not the intrinsic's" ); } diff --git a/crates/perry-runtime/src/object/delete_rest.rs b/crates/perry-runtime/src/object/delete_rest.rs index dc24257bcf..0466d78eba 100644 --- a/crates/perry-runtime/src/object/delete_rest.rs +++ b/crates/perry-runtime/src/object/delete_rest.rs @@ -304,13 +304,15 @@ pub extern "C" fn js_object_delete_field( { return 0; } - if name != "constructor" - && (super::class_registry::class_own_accessor_ptrs(cid, name).is_some() - || super::native_module::class_has_own_method(cid, name) - || super::class_registry::lookup_own_prototype_method(cid, name) - .is_some()) + if name == "constructor" + || super::class_registry::class_own_accessor_ptrs(cid, name).is_some() + || super::native_module::class_has_own_method(cid, name) + || super::class_registry::lookup_own_prototype_method(cid, name).is_some() { - super::class_registry::class_mark_key_deleted(cid, name); + // The member's storage is this object's key (removed + // by the scan below) plus, for a runtime prototype + // assignment, its dispatch entry: remove both. + super::class_registry::class_prototype_method_root_remove(cid, name); super::class_registry::invalidate_class_string_member_order( cid, name, false, ); @@ -798,34 +800,33 @@ fn class_delete_own_key(class_id: u32, name: &str) -> i32 { return 0; } super::class_registry::class_delete_own_dynamic_prop(class_id, name); - super::class_registry::class_mark_key_deleted(class_id, name); + crate::object::class_value::note_static_key_deleted(class_id, name); super::class_registry::invalidate_class_string_member_order(class_id, name, true); 1 } fn delete_class_prototype_key(class_id: u32, name: &str) -> i32 { - if let Some(proto) = super::class_registry::decl_prototype_own_accessor(class_id, name) { - // S2: the accessor is a real property of the declared prototype - // object; delete it there (which also records the class key deleted). - let scope = crate::gc::RuntimeHandleScope::new(); - let proto = scope.root_nanbox_f64(proto); - let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); - return js_object_delete_field( - (proto.get_nanbox_f64().to_bits() & crate::value::POINTER_MASK) as *mut ObjectHeader, - key, - ); - } let has_own = name == "constructor" + || super::class_registry::decl_prototype_own_accessor(class_id, name).is_some() || super::native_module::class_has_own_method(class_id, name) || super::class_registry::lookup_own_prototype_method(class_id, name).is_some(); if !has_own { return 1; } - super::class_registry::class_mark_key_deleted(class_id, name); - super::class_registry::invalidate_class_string_member_order(class_id, name, false); - super::class_registry::invalidate_class_prototype_fast_guards_for_method(name); - crate::typed_feedback::invalidate_method_change(class_id); - 1 + // The members are real properties of the class's prototype object + // (materialized first): the delete happens there. + let proto = super::class_registry::class_decl_prototype_value(class_id); + let js = crate::JSValue::from_bits(proto.to_bits()); + if !js.is_pointer() { + return 1; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let proto = scope.root_nanbox_f64(proto); + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + js_object_delete_field( + (proto.get_nanbox_f64().to_bits() & crate::value::POINTER_MASK) as *mut ObjectHeader, + key, + ) } /// `delete prim.field` (static key): once RequireObjectCoercible has rejected diff --git a/crates/perry-runtime/src/object/descriptor_state.rs b/crates/perry-runtime/src/object/descriptor_state.rs index 000896b7c5..9ca9980f3f 100644 --- a/crates/perry-runtime/src/object/descriptor_state.rs +++ b/crates/perry-runtime/src/object/descriptor_state.rs @@ -644,6 +644,22 @@ pub(crate) fn note_attrs_born_with_keys(obj: usize) { /// Look up the property descriptor for (obj, key). Returns None if no entry exists, /// in which case the JS default `{ writable: true, enumerable: true, configurable: true }` applies. pub(crate) fn get_property_attrs(obj: usize, key: &str) -> Option { + // A function object's own properties, and their attributes, live in + // its bag (`closure::props`): its keys answer. + if crate::closure::is_closure_ptr(obj) { + // SAFETY: a proven live closure; its bag is null or a live object. + let bag = unsafe { crate::closure::props::bag_of(obj) }; + if !bag.is_null() { + let entry = unsafe { + super::key_attrs::object_key_entry(bag as *const ObjectHeader, key.as_bytes()) + }; + if entry != 0 { + return Some(PropertyAttrs { + bits: super::key_attrs::entry_to_attr_bits(entry), + }); + } + } + } // A STORED descriptor wins over the synthesized index default: // `Object.defineProperty` / `Object.freeze` on a wrapper installs a real // entry, and the §10.4.3 default must not shadow it. Synthesis therefore diff --git a/crates/perry-runtime/src/object/descriptors.rs b/crates/perry-runtime/src/object/descriptors.rs index 68409e3918..b0da991d7e 100644 --- a/crates/perry-runtime/src/object/descriptors.rs +++ b/crates/perry-runtime/src/object/descriptors.rs @@ -231,35 +231,6 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu ); } } - // #6943: `js_string_coerce` allocates for every non-heap-string - // key and can run a user `toString` / `valueOf` for an object - // key, so it can trigger a GC that **evacuates**. `obj` — the - // receiver's header, resolved on the line above and - // dereferenced by `own_key_present` / `js_object_get_class_id` - // below — and `obj_value` (passed to `js_class_method_bind`) - // were raw Rust locals across the call. - let scope = crate::gc::RuntimeHandleScope::new(); - let obj_value_handle = scope.root_heap_word_u64(obj_value.to_bits()); - let obj_handle = scope.root_raw_mut_ptr(extract_obj_ptr(obj_value)); - let key_str = crate::builtins::js_string_coerce(key_value); - let obj_value = f64::from_bits(obj_value_handle.get_heap_word_u64()); - let obj = obj_handle.get_raw_mut_ptr::(); - if !obj.is_null() && !key_str.is_null() && !own_key_present(obj, key_str) { - let class_id = super::js_object_get_class_id(obj as *const ObjectHeader); - if class_id != 0 - && !method_name.starts_with('#') - && !super::class_registry::class_is_key_deleted(class_id, &method_name) - && super::class_registry::class_has_own_static_method( - class_id, - &method_name, - ) - { - let leaked: &'static [u8] = method_name.as_bytes().to_vec().leak(); - let value = - super::js_class_method_bind(obj_value, leaked.as_ptr(), leaked.len()); - return build_data_descriptor(value, true, false, true); - } - } } } @@ -441,7 +412,11 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu if let Some(class_id) = class_ref_id(obj_value) { let method_name = metadata_key_to_string(key_value); if let Some(method_name) = method_name { - if super::class_registry::class_is_key_deleted(class_id, &method_name) { + if if class_prototype_ref_id(obj_value).is_some() { + super::class_registry::class_proto_key_deleted(class_id, &method_name) + } else { + super::class_registry::class_static_key_deleted(class_id, &method_name) + } { return f64::from_bits(crate::value::TAG_UNDEFINED); } // Private registry entries retain their source spelling, but @@ -561,23 +536,6 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu } // Static methods are own properties of the class *constructor* // (not the prototype). `getOwnPropertyDescriptor(C, "m")` for a - // `static m() {}` must report a `{ writable, enumerable: false, - // configurable }` data property — `hasOwnProperty(C, "m")` - // already returns true, so without this the two disagreed and - // verifyProperty threw "reading 'enumerable'" on undefined - // (Test262 elements/after-same-line-static-*). - if super::class_prototype_ref_id(obj_value).is_none() - && super::class_registry::class_has_own_static_method(class_id, &method_name) - { - // Bind the static method to the constructor ref to produce a - // callable value, mirroring the `C.m` read path. The name - // bytes are leaked (bounded by the static descriptor set) so - // the pointer js_class_method_bind stashes stays valid. - let leaked: &'static [u8] = method_name.as_bytes().to_vec().leak(); - let value = - super::js_class_method_bind(obj_value, leaked.as_ptr(), leaked.len()); - return build_data_descriptor(value, true, false, true); - } // Static FIELDS are own data properties of the constructor, // created via CreateDataPropertyOrThrow → writable, enumerable, // configurable all true. Codegen registers each declared @@ -1167,6 +1125,44 @@ fn js_object_get_own_property_names_shape(obj_value: f64) -> f64 { } if let Some(class_id) = class_ref_id(obj_value) { let is_prototype_ref = super::class_prototype_ref_id(obj_value).is_some(); + // The class function object's own keys, in its bag's creation + // order: a key deleted and defined again is a new key and comes + // last, as for any object. + if !is_prototype_ref { + if let Some(fo) = crate::object::class_value::class_value_if_minted(class_id) { + let ptr = fo as usize; + // This agent's live class closure (the enclosing `unsafe`). + let names = crate::closure::props::bag_own_key_names(ptr); + let mut out: Vec = Vec::new(); + for intrinsic in ["length", "name", "prototype"] { + if !names.iter().any(|n| n == intrinsic) + && !crate::closure::closure_is_key_deleted(ptr, intrinsic) + && (intrinsic == "prototype" + || !super::class_registry::class_static_key_deleted( + class_id, intrinsic, + )) + { + out.push(intrinsic.to_string()); + } + } + for name in names { + if name.starts_with('#') + || super::field_get_set::is_internal_runtime_key(&name) + { + continue; + } + push_unique_name(&mut out, name); + } + sort_property_names_ecma(&mut out); + let result = crate::array::js_array_alloc(out.len() as u32); + for name in out { + let str_ptr = + crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + crate::array::js_array_push(result, JSValue::string_ptr(str_ptr)); + } + return f64::from_bits((result as u64) | 0x7FFD_0000_0000_0000); + } + } let mut names: Vec = if is_prototype_ref { vec!["constructor".to_string()] } else { @@ -1179,7 +1175,12 @@ fn js_object_get_own_property_names_shape(obj_value: f64) -> f64 { for name in super::class_registry::class_own_string_member_names(class_id, !is_prototype_ref) { - if !super::class_registry::class_is_key_deleted(class_id, &name) { + let deleted = if is_prototype_ref { + super::class_registry::class_proto_key_deleted(class_id, &name) + } else { + super::class_registry::class_static_key_deleted(class_id, &name) + }; + if !deleted { push_unique_name(&mut names, name); } } @@ -1197,7 +1198,7 @@ fn js_object_get_own_property_names_shape(obj_value: f64) -> f64 { // A deleted `length` / `name` is no longer own. && !(!is_prototype_ref && matches!(n.as_str(), "length" | "name") - && super::class_registry::class_is_key_deleted(class_id, n)) + && super::class_registry::class_static_key_deleted(class_id, n)) }); sort_property_names_ecma(&mut names); let result = crate::array::js_array_alloc(names.len() as u32); @@ -1278,45 +1279,46 @@ fn js_object_get_own_property_names_shape(obj_value: f64) -> f64 { } } - // #3655: functions/closures. Own keys are `length`, `name`, then any - // user-attached props, then `prototype` (constructors) — matching V8's - // ordering. All honor `delete`. Reading `keys_array` off a closure - // (below) would be out of bounds. + // #3655: functions/closures. Own keys come in creation order: the + // function object's own property bag holds them in that order, so a + // key deleted and defined again is a new key and comes last, as for + // any object. `length`, `name` and `prototype` a function has not + // materialized into its bag yet were created with the function, + // before every bag key; each honors `delete`. Reading `keys_array` + // off a closure (below) would be out of bounds. if obj_jv.is_pointer() { let ptr = crate::value::js_nanbox_get_pointer(obj_value) as usize; if crate::closure::is_closure_ptr(ptr) { + // `is_closure_ptr` proved a live closure cell. + let bag_keys = crate::closure::props::bag_own_key_names(ptr); + let in_bag = |k: &str| bag_keys.iter().any(|n| n == k); let mut names: Vec = Vec::new(); - if !crate::closure::closure_is_key_deleted(ptr, "length") { - names.push("length".to_string()); + for intrinsic in ["length", "name"] { + if !in_bag(intrinsic) && !crate::closure::closure_is_key_deleted(ptr, intrinsic) + { + names.push(intrinsic.to_string()); + } } - if !crate::closure::closure_is_key_deleted(ptr, "name") { - names.push("name".to_string()); + if !in_bag("prototype") + && crate::closure::closure_has_own_dynamic_prop(ptr, "prototype") + && !crate::closure::closure_is_key_deleted(ptr, "prototype") + { + names.push("prototype".to_string()); } - let has_prototype = crate::closure::closure_has_own_dynamic_prop(ptr, "prototype") - && !crate::closure::closure_is_key_deleted(ptr, "prototype"); - // User-attached props (snapshot is already sorted); the - // built-in slots are emitted explicitly so skip them here. - for (name, _) in crate::closure::closure_dynamic_props_snapshot(ptr) { - if matches!(name.as_str(), "length" | "name" | "prototype") { - continue; - } - if crate::closure::closure_is_key_deleted(ptr, &name) { + for name in bag_keys.iter() { + if crate::closure::closure_is_key_deleted(ptr, name) { continue; } - names.push(name); + push_unique_name(&mut names, name.clone()); } + // Accessors a function keeps outside its bag (the descriptor + // side table) follow the bag keys. for name in super::accessor_descriptor_keys_for_obj(ptr) { - if matches!(name.as_str(), "length" | "name" | "prototype") { - continue; - } if crate::closure::closure_is_key_deleted(ptr, &name) { continue; } push_unique_name(&mut names, name); } - if has_prototype { - names.push("prototype".to_string()); - } sort_property_names_ecma(&mut names); let result = crate::array::js_array_alloc(names.len() as u32); for name in names { @@ -1404,7 +1406,7 @@ fn js_object_get_own_property_names_shape(obj_value: f64) -> f64 { names.push("constructor".to_string()); } for name in super::class_registry::class_own_string_member_names(class_id, false) { - if super::class_registry::class_is_key_deleted(class_id, &name) { + if super::class_registry::class_proto_key_deleted(class_id, &name) { continue; } if physical.contains(&name) { diff --git a/crates/perry-runtime/src/object/field_get_set/class_object_props.rs b/crates/perry-runtime/src/object/field_get_set/class_object_props.rs index f913510351..398f9f2827 100644 --- a/crates/perry-runtime/src/object/field_get_set/class_object_props.rs +++ b/crates/perry-runtime/src/object/field_get_set/class_object_props.rs @@ -246,7 +246,7 @@ pub(super) unsafe fn class_object_name_value( return Some(v); } let class_id = (*obj).class_id; - if super::super::class_registry::class_is_key_deleted(class_id, "name") { + if super::super::class_registry::class_static_key_deleted(class_id, "name") { return None; } let cname = super::super::class_registry::class_name_for_id(class_id)?; diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index 28d2e02863..fcc20710b9 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -254,7 +254,7 @@ pub(crate) fn class_value_get_field( } } if !name.is_empty() { - if super::super::class_registry::class_is_key_deleted(class_id, name) { + if super::super::class_registry::class_static_key_deleted(class_id, name) { // Not an own property any more: the read continues on the // class's [[Prototype]]. return crate::object::class_value::class_prototype_get(class_id, key, class_value); @@ -358,7 +358,7 @@ pub(crate) fn class_value_get_field( // Mid.foo` must let `Sub.foo` inherit `Base.foo`, not // resolve to undefined. Skip the registry read for the // deleted level and keep walking up. - if !super::super::class_registry::class_is_key_deleted(p, name) { + if !super::super::class_registry::class_static_key_deleted(p, name) { let inherited = crate::object::class_value::class_static_get(p, name); if let Some(v) = inherited { return JSValue::from_bits(v.to_bits()); @@ -370,6 +370,13 @@ pub(crate) fn class_value_get_field( } if super::super::class_registry::lookup_static_method_in_chain(class_id, name).is_some() { + // The declaration's own function object, on whichever class + // declares it (`Q.a === P.a`). + if let Some(v) = + crate::object::class_value::inherited_static_method_value(class_id, name) + { + return JSValue::from_bits(v.to_bits()); + } let heap_name = { let layout = std::alloc::Layout::from_size_align(name_len.max(1), 1).unwrap(); let ptr = std::alloc::alloc(layout); @@ -447,7 +454,7 @@ pub(crate) fn class_value_get_field( // `thrown.constructor.name` to label the thrown error. if name == "name" && class_id != 0 - && !super::super::class_registry::class_is_key_deleted(class_id, name) + && !super::super::class_registry::class_static_key_deleted(class_id, name) { if let Some(cname) = super::super::class_registry::class_name_for_id(class_id) { let s = crate::string::js_string_from_bytes(cname.as_ptr(), cname.len() as u32); @@ -457,7 +464,7 @@ pub(crate) fn class_value_get_field( if name == "length" && class_id != 0 && !is_prototype_ref - && !super::super::class_registry::class_is_key_deleted(class_id, name) + && !super::super::class_registry::class_static_key_deleted(class_id, name) { if let Some(length) = super::super::class_registry::class_length_for_id(class_id) { return JSValue::number(length as f64); @@ -1149,7 +1156,7 @@ fn get_field_by_name_past_data_probe( let name = std::str::from_utf8(std::slice::from_raw_parts(name_ptr, name_len)) .unwrap_or(""); if !name.is_empty() - && !super::super::class_registry::class_is_key_deleted(class_id, name) + && !super::super::class_registry::class_static_key_deleted(class_id, name) { if super::super::class_registry::lookup_static_method_in_chain(class_id, name) .is_some() diff --git a/crates/perry-runtime/src/object/field_get_set/has_property.rs b/crates/perry-runtime/src/object/field_get_set/has_property.rs index 53b6638d3e..01c8f5d835 100644 --- a/crates/perry-runtime/src/object/field_get_set/has_property.rs +++ b/crates/perry-runtime/src/object/field_get_set/has_property.rs @@ -199,7 +199,7 @@ unsafe fn class_ref_has_inherited_static_data( Some(parent) if parent != 0 && parent != child => parent, _ => break, }; - if !super::super::class_registry::class_is_key_deleted(parent, name) + if !super::super::class_registry::class_static_key_deleted(parent, name) && super::super::class_registry::class_has_own_dynamic_prop(parent, name) { return true; @@ -446,17 +446,19 @@ pub extern "C" fn js_object_has_property(obj: f64, key: f64) -> f64 { ) }; let present = matches!(name, "prototype" | "name" | "length" | "constructor") - || (!super::super::class_registry::class_is_key_deleted(class_id, name) - && (super::super::class_registry::class_has_own_dynamic_prop( - class_id, name, - ) || super::super::class_registry::lookup_static_method_in_chain( + || (!super::super::class_registry::class_static_key_deleted( + class_id, name, + ) && (super::super::class_registry::class_has_own_dynamic_prop( + class_id, name, + ) + || super::super::class_registry::lookup_static_method_in_chain( class_id, name, ) .is_some() - || super::super::class_registry::static_accessor_in_chain( - class_id, name, - ) - || inherited_data)); + || super::super::class_registry::static_accessor_in_chain( + class_id, name, + ) + || inherited_data)); if present { return nanbox_true; } diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index c04a742c8b..8ec670296c 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -18,7 +18,8 @@ pub extern "C" fn js_object_get_field_by_name_f64( if (obj as usize) > 0 && (obj as usize) < 0x10000 && !key.is_null() { if let Some(name) = unsafe { super::super::has_own_helpers::str_from_string_header(key) } { let class_id = obj as usize as u32; - if name == "name" && !super::super::class_registry::class_is_key_deleted(class_id, name) + if name == "name" + && !super::super::class_registry::class_static_key_deleted(class_id, name) { if let Some(cname) = super::super::class_registry::class_name_for_id(class_id) { let s = crate::string::js_string_from_bytes(cname.as_ptr(), cname.len() as u32); diff --git a/crates/perry-runtime/src/object/field_set_by_name.rs b/crates/perry-runtime/src/object/field_set_by_name.rs index 2c7d3d35d8..48332b0578 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -441,7 +441,7 @@ pub extern "C" fn js_object_set_field_by_name( let is_prototype_ref = super::class_prototype_ref_id(recv).is_some(); if !is_prototype_ref && name == "name" - && !super::class_registry::class_is_key_deleted(class_id, &name) + && !super::class_registry::class_static_key_deleted(class_id, &name) && super::class_registry::lookup_static_method_in_chain(class_id, &name) .is_none() && super::class_registry::class_static_defined_attrs(class_id, &name) diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index ecf3978ac8..f47c761158 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -1761,6 +1761,9 @@ pub(crate) unsafe fn object_is_shaped(obj: *const ObjectHeader) -> bool { // 16-byte header with `meta` last (target_layout.rs): offset 8 LP64, 12 ILP32. const _: () = assert!(std::mem::offset_of!(ObjectHeader, meta) == 16 - size_of::()); +const _: () = assert!( + std::mem::offset_of!(ObjectHeader, parent_class_id) == crate::codegen_abi::OBJECT_SHAPE_OFFSET +); const _: () = assert!(std::mem::size_of::() == 8); pub(crate) mod cell_meta; diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 2bb9a53831..84a86892bf 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -2423,7 +2423,7 @@ pub unsafe extern "C-unwind" fn js_native_call_method( if class_id != 0 && (!class_prototype_fast_guard_invalidated_for_method( class_prototype_method_guard_slot(method_name), - ) || !class_is_key_deleted(class_id, method_name)) + ) || !class_proto_key_deleted(class_id, method_name)) { if let Ok(registry) = CLASS_VTABLE_REGISTRY.read() { if let Some(ref reg) = *registry { @@ -2807,7 +2807,10 @@ pub unsafe extern "C-unwind" fn js_native_call_method( !recv.is_null() && !crate::value::addr_class::is_small_handle(recv as usize) && { let class_id = crate::object::js_object_get_class_id(recv); class_id != 0 - && !crate::object::class_registry::class_is_key_deleted(class_id, method_name) + && !crate::object::class_registry::class_proto_key_deleted( + class_id, + method_name, + ) && crate::object::class_registry::class_chain_has_instance_accessor( class_id, method_name, diff --git a/crates/perry-runtime/src/object/native_call_method/common_methods.rs b/crates/perry-runtime/src/object/native_call_method/common_methods.rs index 3dbf6baaee..8daa76406e 100644 --- a/crates/perry-runtime/src/object/native_call_method/common_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/common_methods.rs @@ -85,7 +85,7 @@ pub(super) unsafe fn dispatch_common( super::has_own_helpers::str_from_string_header(key_str) .map(|key| { matches!(key, "length" | "name" | "prototype") - && !super::class_registry::class_is_key_deleted(class_id, key) + && !super::class_registry::class_static_key_deleted(class_id, key) }) .unwrap_or(false) }; @@ -106,22 +106,20 @@ pub(super) unsafe fn dispatch_common( if let Some(class_id) = super::class_ref_id(object) { let present = super::has_own_helpers::str_from_string_header(key_str) .map(|key| { - if super::class_registry::class_is_key_deleted(class_id, key) { + if super::class_registry::class_static_key_deleted(class_id, key) { false } else if key == "name" - && super::class_registry::lookup_static_method_in_chain( + && !crate::object::class_value::class_static_owns_method( class_id, key, ) - .is_none() { super::class_registry::class_name_for_id(class_id).is_some() } else { crate::object::class_value::class_static_get(class_id, key) .is_some() - || super::class_registry::lookup_static_method_in_chain( + || crate::object::class_value::class_static_owns_method( class_id, key, ) - .is_some() } }) .unwrap_or(false); diff --git a/crates/perry-runtime/src/object/native_call_method/handle_methods.rs b/crates/perry-runtime/src/object/native_call_method/handle_methods.rs index c59e7f2dea..b1950f422c 100644 --- a/crates/perry-runtime/src/object/native_call_method/handle_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/handle_methods.rs @@ -1067,7 +1067,7 @@ pub(super) unsafe fn dispatch_handle( let mut depth = 0u32; while depth < 32 { let deleted = - prototype_mutated && class_is_key_deleted(cur_cid, method_name); + prototype_mutated && class_proto_key_deleted(cur_cid, method_name); // A runtime assignment is an own property of this // exact prototype and replaces the declared vtable // entry. Resolve it first; deletion hides both. diff --git a/crates/perry-runtime/src/object/native_module/class_ref_values.rs b/crates/perry-runtime/src/object/native_module/class_ref_values.rs index fab0d06f12..eb90b4be4a 100644 --- a/crates/perry-runtime/src/object/native_module/class_ref_values.rs +++ b/crates/perry-runtime/src/object/native_module/class_ref_values.rs @@ -118,7 +118,7 @@ fn class_chain_declares(class_id: u32, name: &str, accessors: bool) -> bool { if let Some(vtable) = reg.get(&cid) { // Honor `delete C.prototype.m`: a deleted key must report `false` // from `'m' in new C()`, matching the descriptor/static lookup paths. - if !super::class_registry::class_is_key_deleted(cid, name) + if !super::class_registry::class_proto_key_deleted(cid, name) && (vtable.methods.contains_key(name) || (accessors && vtable.accessor_decl(name).is_some())) { diff --git a/crates/perry-runtime/src/object/object_ops/define_property.rs b/crates/perry-runtime/src/object/object_ops/define_property.rs index 2bc2a3ef52..73744b5ef2 100644 --- a/crates/perry-runtime/src/object/object_ops/define_property.rs +++ b/crates/perry-runtime/src/object/object_ops/define_property.rs @@ -78,7 +78,6 @@ unsafe fn define_class_prototype_method(target_cid: u32, name: &str, value_bits: super::super::class_registry::class_prototype_method_root_remove( target_cid, name, ); - super::super::class_registry::class_unmark_key_deleted(target_cid, name); super::super::class_registry::invalidate_class_prototype_fast_guards_for_method( name, ); diff --git a/crates/perry-runtime/src/object/object_ops/has_own.rs b/crates/perry-runtime/src/object/object_ops/has_own.rs index 3aeed7440d..d029721766 100644 --- a/crates/perry-runtime/src/object/object_ops/has_own.rs +++ b/crates/perry-runtime/src/object/object_ops/has_own.rs @@ -221,15 +221,13 @@ pub extern "C" fn js_object_has_own(obj_value: f64, key_value: f64) -> f64 { .map(|key| { if super::super::field_get_set::is_internal_runtime_key(key) { false - } else if super::super::class_registry::class_is_key_deleted(class_id, key) { + } else if super::super::class_registry::class_static_key_deleted(class_id, key) + { false } else if matches!(key, "length" | "prototype") { true } else if key == "name" - && super::super::class_registry::lookup_static_method_in_chain( - class_id, key, - ) - .is_none() + && !crate::object::class_value::class_static_owns_method(class_id, key) { super::super::class_registry::class_name_for_id(class_id).is_some() } else { @@ -237,13 +235,11 @@ pub extern "C" fn js_object_has_own(obj_value: f64, key_value: f64) -> f64 { crate::object::class_value::class_static_get(class_id, key).is_some(); has_public_data || (!key.starts_with('#') - && (super::super::class_registry::lookup_static_method_in_chain( + && (crate::object::class_value::class_static_owns_method( + class_id, key, + ) || crate::object::class_value::class_static_has_own_accessor( class_id, key, - ) - .is_some() - || crate::object::class_value::class_static_has_own_accessor( - class_id, key, - ))) + ))) } }) .unwrap_or(false); @@ -467,7 +463,7 @@ pub extern "C" fn js_object_has_own(obj_value: f64, key_value: f64) -> f64 { super::super::class_registry::class_id_for_decl_prototype_object(obj as usize) { if let Some(key) = super::super::has_own_helpers::str_from_string_header(key_str) { - if !super::super::class_registry::class_is_key_deleted(cid, key) + if !super::super::class_registry::class_proto_key_deleted(cid, key) && (key == "constructor" || (!key.starts_with('#') && super::super::native_module::class_has_own_method(cid, key))) diff --git a/crates/perry-runtime/src/object/property_key.rs b/crates/perry-runtime/src/object/property_key.rs index 63036a0e4a..7f170bad21 100644 --- a/crates/perry-runtime/src/object/property_key.rs +++ b/crates/perry-runtime/src/object/property_key.rs @@ -653,6 +653,7 @@ mod property_key_tests { 0, 0, 1, + 0, ); let method = crate::object::class_registry::lookup_class_symbol_method_in_chain( class_id, sym_key, false, diff --git a/crates/perry-runtime/src/object/this_binding.rs b/crates/perry-runtime/src/object/this_binding.rs index 15f47fdbf6..5b7157c662 100644 --- a/crates/perry-runtime/src/object/this_binding.rs +++ b/crates/perry-runtime/src/object/this_binding.rs @@ -541,3 +541,36 @@ pub extern "C" fn js_derived_this_check_current() -> f64 { } f64::from_bits(crate::value::TAG_UNDEFINED) } + +/// Prologue of a static method's closure-convention entry +/// (`__clo`): the call's `this` (IMPLICIT_THIS, set by whatever +/// called the function object: `C.m()`, `f.call(x)`, a bare `f()`) becomes the +/// body's `this`; class `class_id` (the declaring class) is its +/// static-private owner, whatever the receiver. Paired with +/// [`js_static_method_entry_leave`] after the body returns. +// #1561-style force-keep: only generated IR calls this. +#[cfg(feature = "keepalive-anchors")] +#[used(compiler)] +static KEEP_JS_STATIC_METHOD_ENTRY_ENTER: extern "C" fn(u32) = js_static_method_entry_enter; + +#[no_mangle] +pub extern "C" fn js_static_method_entry_enter(class_id: u32) { + // Minting the owner's function object allocates: root `this` across it. + let this_scope = crate::gc::RuntimeHandleScope::new(); + let this = this_scope.root_nanbox_f64(js_implicit_this_get()); + static_private_owner_push(super::class_value::class_value(class_id)); + static_this_arm(this.get_nanbox_f64()); +} + +/// Epilogue of a static method's closure-convention entry: pops the owner the +/// prologue pushed and drops an override the body never consumed. +// #1561-style force-keep: only generated IR calls this. +#[cfg(feature = "keepalive-anchors")] +#[used(compiler)] +static KEEP_JS_STATIC_METHOD_ENTRY_LEAVE: extern "C" fn() = js_static_method_entry_leave; + +#[no_mangle] +pub extern "C" fn js_static_method_entry_leave() { + static_private_owner_pop(); + static_this_disarm(); +} diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 133d51a6ba..bce6b4ef18 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -3714,10 +3714,6 @@ "file": "crates/perry-runtime/src/object/class_registry/state.rs", "name": "CLASS_DECL_PROTOTYPE_OBJECTS" }, - { - "file": "crates/perry-runtime/src/object/class_registry/state.rs", - "name": "CLASS_DELETED_KEYS" - }, { "file": "crates/perry-runtime/src/object/class_registry/state.rs", "name": "CLASS_DYNAMIC_PARENT_VALUE" diff --git a/test-files/test_gap_class_computed_static_method.ts b/test-files/test_gap_class_computed_static_method.ts new file mode 100644 index 0000000000..45ff7dc718 --- /dev/null +++ b/test-files/test_gap_class_computed_static_method.ts @@ -0,0 +1,46 @@ +// A computed-name ClassBody static method is an own data property of the class +// function object, like a named one: one function object per (class, method), +// `name` = the key, the declared attributes, inherited by a subclass, and a +// direct call runs it only while the property still holds it. +const k = "comp"; +const w = "who"; +let order: string[] = []; +function key(s: string): string { + order.push(s); + return s; +} +class A { + static first() { + return 0; + } + static [k](x: number) { + return x + 1; + } + static [key("late")](a: number, b = 2) { + return a + b; + } +} +console.log(order.join(",")); +console.log(Object.getOwnPropertyNames(A).join(",")); +console.log(typeof A.comp, A.comp(1), A.comp.name, A.comp.length); +console.log((A as any).late.name, (A as any).late.length, (A as any).late(1)); +const d = Object.getOwnPropertyDescriptor(A, "comp")!; +console.log(d.writable, d.enumerable, d.configurable, d.value === A.comp); +console.log(A.comp === A.comp, Object.keys(A).length); +class B extends A {} +console.log(B.comp === A.comp, B.comp(2), Object.hasOwn(B, "comp")); +(A as any).comp = function (x: number) { + return -x; +}; +console.log(B.comp(2), A.comp(3)); +delete (A as any).comp; +console.log(typeof (A as any).comp, "comp" in A, "comp" in B); +class W { + static [w]() { + return this === W ? "W" : this === V ? "V" : "other"; + } +} +class V extends W {} +console.log((W as any).who(), (V as any).who()); +const f = (W as any).who; +console.log(f.call(V), String(f).startsWith("[w]") || String(f).includes("return this")); diff --git a/test-files/test_gap_class_delete_redefine.ts b/test-files/test_gap_class_delete_redefine.ts new file mode 100644 index 0000000000..ca61eaaeed --- /dev/null +++ b/test-files/test_gap_class_delete_redefine.ts @@ -0,0 +1,44 @@ +// delete is a real delete, and a later definition brings the key back +class A { + static s() { return "A.s"; } + static f = 1; + m() { return "A.m"; } +} +class B extends A { + static s() { return "B.s"; } + m() { return "B.m"; } +} +const b = new B(); +console.log(B.s(), b.m()); +delete (B as any).s; +console.log(B.s(), Object.prototype.hasOwnProperty.call(B, "s"), Object.getOwnPropertyNames(B).join(",")); +delete (B.prototype as any).m; +console.log(b.m(), Object.prototype.hasOwnProperty.call(B.prototype, "m"), "m" in b); +(B as any).s = function () { return "B.s2"; }; +console.log(B.s(), Object.getOwnPropertyDescriptor(B, "s")!.enumerable); +(B.prototype as any).m = function () { return "B.m2"; }; +console.log(b.m(), Object.prototype.hasOwnProperty.call(B.prototype, "m")); +Object.defineProperty(B, "s", { value: () => "B.s3", writable: false, enumerable: false, configurable: true }); +console.log(B.s(), JSON.stringify(Object.getOwnPropertyDescriptor(B, "s")!.writable)); +// A static store of the same name never resurrects a deleted prototype member +class C { k() { return "C.k"; } } +const c = new C(); +delete (C.prototype as any).k; +(C as any).k = 5; +console.log(typeof (c as any).k, (C as any).k, "k" in c); +// intrinsic name/length: delete, then define again +class D { constructor(a: number, b: number) {} } +delete (D as any).name; +console.log(Object.prototype.hasOwnProperty.call(D, "name"), JSON.stringify(D.name)); +Object.defineProperty(D, "name", { value: "Dee" }); +console.log(D.name, D.length); +delete (D as any).length; +console.log(D.length, Object.getOwnPropertyNames(D).join(",")); +// static field delete then re-store +delete (A as any).f; +console.log((A as any).f, (B as any).f, Object.prototype.hasOwnProperty.call(A, "f")); +(A as any).f = 7; +console.log((A as any).f, (B as any).f); +// delete the parent's static: the child now misses it too +delete (A as any).s; +console.log(typeof (B as any).s, typeof (A as any).s); diff --git a/test-files/test_gap_class_static_method_props.ts b/test-files/test_gap_class_static_method_props.ts new file mode 100644 index 0000000000..2d5b94dfb1 --- /dev/null +++ b/test-files/test_gap_class_static_method_props.ts @@ -0,0 +1,77 @@ +// static methods are own data properties of the class function object +class P { + static a() { return "P.a:" + (this === P ? "P" : (this as any).name); } + static get g() { return 1; } + static b(x: number, y: number) { return x + y; } + static z = 3; +} +class Q extends P {} +const d = Object.getOwnPropertyDescriptor(P, "a")!; +console.log(typeof d.value, d.writable, d.enumerable, d.configurable); +console.log(P.a === P.a, Q.a === P.a, d.value === P.a); +console.log(Object.prototype.hasOwnProperty.call(Q, "a"), Object.keys(P).join(",")); +console.log(Object.getOwnPropertyNames(P).join(",")); +console.log(P.a(), Q.a(), P.b.length, P.b.name, P.b(2, 3)); +const saved = P.a; +console.log(saved.call(Q)); +(P as any).a = function () { return "replaced"; }; +console.log(P.a(), Q.a(), saved.call(P)); +delete (P as any).a; +console.log(typeof (P as any).a, typeof (Q as any).a, "a" in P); +for (const k in P) console.log("enum", k); +console.log(Object.isFrozen(Object.freeze(Q)), Object.getOwnPropertyDescriptor(P, "b")!.configurable); +// One call site, armed before the store: its memo must see the store. +class R { + static m() { return "R.m"; } +} +class S extends R {} +function callBoth() { return R.m() + "," + S.m(); } +function callValue(c: any) { return c.m(); } +for (let i = 0; i < 3; i++) console.log(callBoth(), callValue(R), callValue(S)); +(R as any).m = function () { return "stored:" + (this === S ? "S" : "R"); }; +console.log(callBoth(), callValue(R), callValue(S)); +(S as any).m = function () { return "own S"; }; +console.log(callBoth(), callValue(S)); +delete (R as any).m; +delete (S as any).m; +console.log(typeof (R as any).m, typeof (S as any).m); +// A class nothing inherits from: its shape changes only because the +// declaration was replaced, redefined or deleted. +class T { + static m() { return "T.m"; } +} +function callT() { return T.m(); } +function callTv(c: any) { return c.m(); } +for (let i = 0; i < 3; i++) console.log(callT(), callTv(T)); +(T as any).m = function () { return "T stored"; }; +console.log(callT(), callTv(T)); +Object.defineProperty(T, "m", { value: function () { return "T defined"; } }); +console.log(callT(), callTv(T)); +delete (T as any).m; +try { callT(); } catch (e) { console.log("callT", e instanceof TypeError); } +try { callTv(T); } catch (e) { console.log("callTv", e instanceof TypeError); } +// Every store form over an armed site's declaration reaches the call. +const forms: [string, (R: any) => void][] = [ + ["assign", (R) => { R.m = function () { return "assign"; }; }], + ["define", (R) => { Object.defineProperty(R, "m", { value: function () { return "define"; } }); }], + ["reflect", (R) => { Reflect.set(R, "m", function () { return "reflect"; }); }], + ["assignObj", (R) => { Object.assign(R, { m: function () { return "assignObj"; } }); }], + ["defineAll", (R) => { Object.defineProperties(R, { m: { value: function () { return "defineAll"; } } }); }], +]; +class A1 { static m() { return "decl"; } } +function s1() { return A1.m(); } +class A2 { static m() { return "decl"; } } +function s2() { return A2.m(); } +class A3 { static m() { return "decl"; } } +function s3() { return A3.m(); } +class A4 { static m() { return "decl"; } } +function s4() { return A4.m(); } +class A5 { static m() { return "decl"; } } +function s5() { return A5.m(); } +const cs: any[] = [A1, A2, A3, A4, A5]; +const ss = [s1, s2, s3, s4, s5]; +for (let i = 0; i < 5; i++) { + ss[i](); ss[i](); + forms[i][1](cs[i]); + console.log(forms[i][0], ss[i]()); +} From 2b50982375a0f72b093a530fc9a84ae574e81249 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 29 Sep 2026 06:09:50 +0000 Subject: [PATCH 04/11] perf(codegen): a static call's guard hit is one shape read and compare per class The inline hit no longer tests "armed?" and no longer builds a 64-bit key: an unarmed memo points at a per-site constant whose own-property word points at itself and whose shape word (0) never equals a half of the unarmed key, and each class's shape word is compared with its own 32-bit half of the key. Every test is expected to pass, so the miss and property paths go out of line and the hit falls through to the direct call: load memo.c, load its props, compare the shape word, branch. Static call instructions per call (scall, 10M-20M slope), base / before / after: direct 65 / 82 / 71, inherited 111 / 134 / 125, value receiver 129 / 151 / 142. --- changelog.d/class-static-call-guard-cheap.md | 4 + .../perry-codegen/src/expr/static_method.rs | 114 ++++++++++-------- .../perry-runtime/src/object/class_value.rs | 13 +- 3 files changed, 80 insertions(+), 51 deletions(-) create mode 100644 changelog.d/class-static-call-guard-cheap.md diff --git a/changelog.d/class-static-call-guard-cheap.md b/changelog.d/class-static-call-guard-cheap.md new file mode 100644 index 0000000000..501ceea5ec --- /dev/null +++ b/changelog.d/class-static-call-guard-cheap.md @@ -0,0 +1,4 @@ +Made a compiled `C.m()` static call cheaper: the check that the class still +holds the declared method is now one shape-word read and compare per class +the call reads (a direct call costs 6 instructions more than an unguarded +call, down from 17). diff --git a/crates/perry-codegen/src/expr/static_method.rs b/crates/perry-codegen/src/expr/static_method.rs index e696c60cfc..f28427c23e 100644 --- a/crates/perry-codegen/src/expr/static_method.rs +++ b/crates/perry-codegen/src/expr/static_method.rs @@ -22,17 +22,22 @@ use super::{ /// declaration's function object, a fact carried by the class function /// objects' shapes. The site's memo is a runtime `StaticCallMemo` (four /// words; thread-local when the program starts workers, so each agent arms -/// its own). The hit is inline: +/// its own) holding the (pinned) class function objects. The hit is inline, +/// one shape word per class the read consults: /// /// ```text -/// c = memo.c ; c != 0 [&& receiver == memo.c_value] else MISS -/// k = [[c + PROPS] + SHAPE] | [[memo.owner + PROPS] + SHAPE] << 32 -/// k == memo.key else MISS +/// [receiver == memo.c_value] else MISS +/// [[memo.c + PROPS] + SHAPE] == low half of memo.key else MISS +/// [[[memo.owner + PROPS] + SHAPE] == high half of memo.key else MISS] /// MISS: ok = miss_fn(miss_args..., memo) (re-validates, re-arms) /// ``` /// -/// `same_owner`: the class the call names declares the body itself, so one -/// shape word is both halves. `receiver_bits`: a site whose receiver is a +/// Never armed, both object words point at the site's constant, whose +/// own-property word points at itself and whose shape word (0) never equals +/// a half of the unarmed key (all ones). +/// +/// `same_owner`: the class the call names declares the body itself, so its +/// one shape word is both halves. `receiver_bits`: a site whose receiver is a /// value must also be looking at the memo's class function object. Returns /// the i1 "the body may run directly". pub(crate) fn emit_static_call_guard( @@ -45,74 +50,89 @@ pub(crate) fn emit_static_call_guard( use crate::types::I1; let site = ctx.ic_site_counter; ctx.ic_site_counter += 1; - let memo = format!( - "@{}_smemo", - crate::expr::inline_cache_global_name(ctx, site) - ); + let site_name = crate::expr::inline_cache_global_name(ctx, site); + let memo = format!("@{site_name}_smemo"); + let unarmed = format!("@{site_name}_sunarmed"); let tls = if crate::codegen::program_has_worker() { "thread_local " } else { "" }; + // `[unarmed + PROPS]` is `unarmed`; `[unarmed + SHAPE]` is 0. + let props_words = crate::runtime_abi::CLOSURE_PROPS_OFFSET / 8; + debug_assert!(crate::runtime_abi::OBJECT_SHAPE_OFFSET + 4 <= 8 * props_words); + ctx.typed_parse_rodata.push(format!( + "{unarmed} = private constant {{ [{props_words} x i64], ptr }} {{ [{props_words} x i64] zeroinitializer, ptr {unarmed} }}, align 8" + )); ctx.typed_parse_rodata.push(format!( - "{memo} = private {tls}global [4 x i64] [i64 -1, i64 0, i64 0, i64 0], align 8" + "{memo} = private {tls}global {{ i64, ptr, ptr, i64 }} {{ i64 -1, ptr {unarmed}, ptr {unarmed}, i64 0 }}, align 8" )); let mut args: Vec<(crate::types::LlvmType, &str)> = miss_args.iter().map(|(t, v)| (*t, v.as_str())).collect(); args.push((PTR, &memo)); - // The inline hit reads LP64 layouts (8-byte memo words, `ClosureHeader` - // props at 16); other targets always ask the runtime. + // The inline hit reads little-endian LP64 layouts (8-byte memo words, the + // key's halves); other targets always ask the runtime. let triple = ctx.target_triple; - let lp64 = - (triple.starts_with("x86_64") || triple.starts_with("aarch64")) && !triple.contains("32"); - if !lp64 { + let lp64_le = (triple.starts_with("x86_64") || triple.starts_with("aarch64")) + && !triple.starts_with("aarch64_be") + && !triple.contains("32"); + if !lp64_le { let ok = ctx.block().call(I32, miss_fn, &args); return ctx.block().icmp_ne(I32, &ok, "0"); } - let word = |ctx: &mut FnCtx<'_>, offset: usize| -> String { + let miss_idx = ctx.new_block("static_guard.miss"); + let join_idx = ctx.new_block("static_guard.join"); + let miss_l = ctx.block_label(miss_idx); + let join_l = ctx.block_label(join_idx); + // One test per block, each expected to pass: the hit falls straight + // through to the direct call, every miss branches out of line. + let test = |ctx: &mut FnCtx<'_>, pass: &str, last: bool| { + let pass = ctx + .block() + .call(I1, "llvm.expect.i1", &[(I1, pass), (I1, "true")]); + if last { + ctx.block().cond_br(&pass, &join_l, &miss_l); + } else { + let next = ctx.new_block("static_guard.check"); + let next_l = ctx.block_label(next); + ctx.block().cond_br(&pass, &next_l, &miss_l); + ctx.current_block = next; + } + }; + let memo_word = |ctx: &mut FnCtx<'_>, ty: crate::types::LlvmType, offset: usize| -> String { let p = ctx .block() .gep(crate::types::I8, &memo, &[(I64, &offset.to_string())]); - ctx.block().load(I64, &p) + ctx.block().load(ty, &p) }; - let shape_word = |ctx: &mut FnCtx<'_>, fo: &str| -> String { - let fo = ctx.block().inttoptr(I64, fo); + let shape_matches = |ctx: &mut FnCtx<'_>, fo_offset: usize, key_offset: usize| -> String { + let fo = memo_word(ctx, PTR, fo_offset); let props = crate::runtime_abi::CLOSURE_PROPS_OFFSET.to_string(); let pp = ctx.block().gep(crate::types::I8, &fo, &[(I64, &props)]); let bag = ctx.block().load(PTR, &pp); let shape = crate::runtime_abi::OBJECT_SHAPE_OFFSET.to_string(); let sp = ctx.block().gep(crate::types::I8, &bag, &[(I64, &shape)]); let w = ctx.block().load(I32, &sp); - ctx.block().zext(I32, &w, I64) + let k = memo_word(ctx, I32, key_offset); + ctx.block().icmp_eq(I32, &w, &k) }; - let check_idx = ctx.new_block("static_guard.check"); - let miss_idx = ctx.new_block("static_guard.miss"); - let join_idx = ctx.new_block("static_guard.join"); - let check_l = ctx.block_label(check_idx); - let miss_l = ctx.block_label(miss_idx); - let join_l = ctx.block_label(join_idx); - let c = word(ctx, crate::runtime_abi::STATIC_CALL_MEMO_C_OFFSET); - let mut armed = ctx.block().icmp_ne(I64, &c, "0"); if let Some(bits) = receiver_bits { - let v = word(ctx, crate::runtime_abi::STATIC_CALL_MEMO_VALUE_OFFSET); + let v = memo_word(ctx, I64, crate::runtime_abi::STATIC_CALL_MEMO_VALUE_OFFSET); let same = ctx.block().icmp_eq(I64, bits, &v); - armed = ctx.block().and(I1, &armed, &same); + test(ctx, &same, false); } - ctx.block().cond_br(&armed, &check_l, &miss_l); - ctx.current_block = check_idx; - let sc = shape_word(ctx, &c); - let so = if same_owner { - sc.clone() - } else { - let o = word(ctx, crate::runtime_abi::STATIC_CALL_MEMO_OWNER_OFFSET); - shape_word(ctx, &o) - }; - let hi = ctx.block().shl(I64, &so, "32"); - let key = ctx.block().or(I64, &sc, &hi); - let memo_key = word(ctx, crate::runtime_abi::STATIC_CALL_MEMO_KEY_OFFSET); - let hit = ctx.block().icmp_eq(I64, &key, &memo_key); - let check_pred = ctx.block().label.clone(); - ctx.block().cond_br(&hit, &join_l, &miss_l); + let key = crate::runtime_abi::STATIC_CALL_MEMO_KEY_OFFSET; + let c_ok = shape_matches(ctx, crate::runtime_abi::STATIC_CALL_MEMO_C_OFFSET, key); + test(ctx, &c_ok, same_owner); + if !same_owner { + let o_ok = shape_matches( + ctx, + crate::runtime_abi::STATIC_CALL_MEMO_OWNER_OFFSET, + key + 4, + ); + test(ctx, &o_ok, true); + } + let hit_pred = ctx.block().label.clone(); ctx.current_block = miss_idx; let ok = ctx.block().call(I32, miss_fn, &args); let ok = ctx.block().icmp_ne(I32, &ok, "0"); @@ -120,7 +140,7 @@ pub(crate) fn emit_static_call_guard( ctx.block().br(&join_l); ctx.current_block = join_idx; ctx.block() - .phi(I1, &[("true", &check_pred), (&ok, &miss_pred)]) + .phi(I1, &[("true", &hit_pred), (&ok, &miss_pred)]) } fn downgrade_unknown_call_args(ctx: &mut FnCtx<'_>, args: &[Expr]) { diff --git a/crates/perry-runtime/src/object/class_value.rs b/crates/perry-runtime/src/object/class_value.rs index 9549bfa0f9..b3e84eea38 100644 --- a/crates/perry-runtime/src/object/class_value.rs +++ b/crates/perry-runtime/src/object/class_value.rs @@ -624,10 +624,15 @@ pub unsafe extern "C" fn js_class_static_call_guard( /// A static-call site's memo (`perry-codegen/src/expr/static_method.rs`), /// four words the emitted hit reads (`perry_abi::STATIC_CALL_MEMO_*`): /// `key` = (C's shape word | owner's shape word << 32) of the last -/// validation, and the two class function objects whose shape words the hit -/// loads (pinned for the agent's life, so the pointers never go stale), plus -/// C's function object as a value, for a site whose receiver is a value. -/// `c == 0` means never armed: the hit tests it before any load. +/// validation; the two class function objects whose own-property objects' +/// shape words the hit loads (pinned for the agent's life, so the pointers +/// never go stale); and C's function object as a value, for a site whose +/// receiver is a value. +/// +/// Never armed, `c` and `owner` point at a constant of the site's whose +/// own-property word points at itself, a shape word of 0, and `key` is all +/// ones, so the hit needs no "armed?" test: that shape word never equals a +/// half of the unarmed key. #[repr(C)] pub struct StaticCallMemo { pub key: u64, From 91324d3bb9f01dba72323cd620ae65100fbc9969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 29 Sep 2026 05:02:53 +0000 Subject: [PATCH 05/11] fix(codegen): register each class by its identity, not its name The registration pass (class names, methods, static methods and their function-object entries, constructors, accessors) iterated the name-keyed class table, so of two classes sharing a name only the last was registered. It now iterates the module's classes and keys each by its ClassId. static_symbol_hygiene again expects both same-named classes' static entries; a gap fixture covers same-named classes in different functions and blocks, each with statics and static methods. --- changelog.d/class-registration-by-identity.md | 3 + crates/perry-codegen/src/codegen/artifacts.rs | 1 + .../perry-codegen/src/codegen/string_pool.rs | 91 ++++-------------- .../tests/static_symbol_hygiene.rs | 20 +++- test-files/test_gap_class_same_name_scopes.ts | 92 +++++++++++++++++++ 5 files changed, 131 insertions(+), 76 deletions(-) create mode 100644 changelog.d/class-registration-by-identity.md create mode 100644 test-files/test_gap_class_same_name_scopes.ts diff --git a/changelog.d/class-registration-by-identity.md b/changelog.d/class-registration-by-identity.md new file mode 100644 index 0000000000..115742fc48 --- /dev/null +++ b/changelog.d/class-registration-by-identity.md @@ -0,0 +1,3 @@ +Fixed class registration being keyed by class name: two classes with the same +name in one module each register their own methods, static methods, accessors +and constructors under their own class id. diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index e6561fe798..6abbc0cac2 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -1012,6 +1012,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { class_header_image_inits, class_ids, class_table, + &hir.classes, &hir.class_display_names, &class_source_text, &ctor_arity_overrides, diff --git a/crates/perry-codegen/src/codegen/string_pool.rs b/crates/perry-codegen/src/codegen/string_pool.rs index 95731d3722..51a14ef84d 100644 --- a/crates/perry-codegen/src/codegen/string_pool.rs +++ b/crates/perry-codegen/src/codegen/string_pool.rs @@ -130,6 +130,11 @@ pub(super) fn emit_string_pool( class_header_image_inits: &std::collections::HashMap, class_ids: &HashMap, classes: &HashMap, + // The classes this module defines, by identity: the registration loops + // below (names, methods, static methods and their function-object + // entries, constructors, accessors) key each class by its ClassId, never + // by a name (`classes` above maps names, and two classes may share one). + module_classes: &[perry_hir::Class], // #5592: user-visible `.name` overrides keyed by ClassId, for classes // whose HIR registration key was uniquified away from their JS name. class_display_names: &HashMap, @@ -348,21 +353,18 @@ pub(super) fn emit_string_pool( // Pre-allocate string constants for class-name registration. We need // these BEFORE `init_fn` is created, because once `init_fn` borrows // `llmod` we can no longer mutate the module's constant pool. (#1021.) + // Every class this module defines, keyed by identity (its ClassId). + // Imported stubs are not here: the defining module registers them. + let local_classes: Vec<(u32, &perry_hir::Class)> = module_classes + .iter() + .filter(|c| c.id != 0) + .map(|c| (c.id, c)) + .collect(); let mut named_class_name_constants: Vec<(u32, String, usize)> = Vec::new(); { let mut named: Vec<(u32, String)> = Vec::new(); - for (class_name, class) in classes.iter() { - // Imported stubs (id == 0) use consumer lookup keys, which may - // be aliases or synthetic namespace keys. Only the defining - // module owns the JavaScript display name; an importer must not - // overwrite it when its string initializer runs. - if class.id == 0 || *class_name != class.name { - continue; - } - let cid = match class_ids.get(class_name).copied() { - Some(c) if c != 0 => c, - _ => continue, - }; + for &(cid, class) in &local_classes { + let class_name = &class.name; if !class_name.starts_with("__AnonShape_") { // #5592: prefer the recorded JS name when the registration // key was uniquified (e.g. a second `C = class {…}`). @@ -903,30 +905,8 @@ pub(super) fn emit_string_pool( // ctor's `arguments` / rest slot correctly (a zero-declared-param parent // that reads `arguments`, e.g. tsc's emitted pass-through ctor). let mut ctor_flag_regs: Vec<(u32, bool, bool)> = Vec::new(); - for (class_name, class) in classes.iter() { - // Refs #486: skip alias keys (class_table now contains both the - // canonical name and self-binding aliases like `_X` from - // `var X = class _X`); the symbol emission iterates by canonical - // class.name. Without this skip the alias key generates bogus - // symbol names like `perry_method____X__method` (extra - // leading underscore from sanitize("_X")) that don't resolve at - // link time. - if *class_name != class.name { - continue; - } - // Imported class stubs carry id == 0 (they're typed-name - // placeholders for cross-module dispatch; the defining module's init - // registers their methods). Skip them here so we don't re-emit the - // registration. Previously this filter was `method.body.is_empty()`; - // the id check is equivalent for stubs and also catches getter/setter - // and property-decorator init that legitimately has an empty body. - if class.id == 0 { - continue; - } - let cid = match class_ids.get(class_name) { - Some(&c) if c != 0 => c, - _ => continue, - }; + for &(cid, class) in &local_classes { + let class_name = &class.name; for method in &class.methods { let llvm_name = format!( "perry_method_{}__{}__{}", @@ -1461,24 +1441,8 @@ pub(super) fn emit_string_pool( // (class_id, prop_name, llvm_symbol, is_static) — static accessors register // onto the class constructor (CLASS_STATIC_ACCESSORS), not the instance vtable. let mut getter_pairs: Vec<(u32, String, String, bool, u32)> = Vec::new(); - for (class_name, class) in classes.iter() { - // Refs #486: skip alias keys (see method-emission loop above). - if *class_name != class.name { - continue; - } - // Imported class stubs carry id == 0 (they're typed-name - // placeholders for cross-module dispatch; the defining module's init - // registers their methods). Skip them here so we don't re-emit the - // registration. Previously this filter was `method.body.is_empty()`; - // the id check is equivalent for stubs and also catches getter/setter - // and property-decorator init that legitimately has an empty body. - if class.id == 0 { - continue; - } - let cid = match class_ids.get(class_name).copied() { - Some(c) if c != 0 => c, - _ => continue, - }; + for &(cid, class) in &local_classes { + let class_name = &class.name; for (prop, getter_fn) in &class.getters { // The local-emit path at codegen.rs:1858 prepends `__get_` // to the HIR-assigned getter name (`get_`), giving @@ -1565,23 +1529,8 @@ pub(super) fn emit_string_pool( // the runtime fell back to the setter's ABI arity (1), over-counting the // defaulted param. let mut setter_pairs: Vec<(u32, String, String, bool, u32, u32)> = Vec::new(); - for (class_name, class) in classes.iter() { - if *class_name != class.name { - continue; - } - // Imported class stubs carry id == 0 (they're typed-name - // placeholders for cross-module dispatch; the defining module's init - // registers their methods). Skip them here so we don't re-emit the - // registration. Previously this filter was `method.body.is_empty()`; - // the id check is equivalent for stubs and also catches getter/setter - // and property-decorator init that legitimately has an empty body. - if class.id == 0 { - continue; - } - let cid = match class_ids.get(class_name).copied() { - Some(c) if c != 0 => c, - _ => continue, - }; + for &(cid, class) in &local_classes { + let class_name = &class.name; for (prop, setter_fn) in &class.setters { let is_static = class.static_accessor_fn_ids.contains(&setter_fn.id); let llvm_name = if is_static { diff --git a/crates/perry-codegen/tests/static_symbol_hygiene.rs b/crates/perry-codegen/tests/static_symbol_hygiene.rs index 5c2027de85..068b79e120 100644 --- a/crates/perry-codegen/tests/static_symbol_hygiene.rs +++ b/crates/perry-codegen/tests/static_symbol_hygiene.rs @@ -227,16 +227,15 @@ fn duplicate_class_static_methods_use_class_id_in_symbols() { ), 1 ); - // This module hands codegen two classes of one name; the registration - // pass reads the name-keyed class table, which keeps the last (c12), so - // only its static method is registered and gets the function-object - // entry. (A real module's classes never share a table name.) + // This module hands codegen two classes of one name. Registration keys + // each class by its ClassId, so both static methods are registered, each + // with its own function-object entry `__clo`. assert_eq!( count( &ir, "define double @perry_static_marked_symbol_hygiene_ts__x__c11__lex__clo(" ), - 0 + 1 ); // The body, and its function object's closure-convention entry // `__clo`: exactly one definition each. @@ -254,6 +253,17 @@ fn duplicate_class_static_methods_use_class_id_in_symbols() { ), 1 ); + // Each class registers its own static method under its own id. + for cid in [11, 12] { + assert_eq!( + count( + &ir, + &format!("call void @js_register_class_static_method_entry(i64 {cid}, ") + ), + 1, + "class {cid}" + ); + } assert_eq!( count( &ir, diff --git a/test-files/test_gap_class_same_name_scopes.ts b/test-files/test_gap_class_same_name_scopes.ts new file mode 100644 index 0000000000..41f29ffc16 --- /dev/null +++ b/test-files/test_gap_class_same_name_scopes.ts @@ -0,0 +1,92 @@ +// Two (and more) classes with the same name in different scopes are distinct +// classes: each has its own static data properties, static methods (own +// function objects running their own bodies), inheritance and identity. + +function makeA() { + class Box { + static tag = "a"; + static count = 1; + static make(x: number) { + return x + 1; + } + static who() { + return "A:" + this.tag; + } + get kind() { + return "boxA"; + } + } + return Box; +} + +function makeB() { + class Box { + static tag = "b"; + static make(x: number) { + return x * 10; + } + static who() { + return "B:" + this.tag; + } + static only() { + return "only-b"; + } + get kind() { + return "boxB"; + } + } + return Box; +} + +const A = makeA(); +const B = makeB(); +console.log(A.name, B.name, A === B); +console.log(A.tag, B.tag, (A as any).count, (B as any).count); +console.log(A.make(1), B.make(1)); +console.log(A.who(), B.who()); +console.log(typeof (A as any).only, typeof (B as any).only); +console.log(A.make === B.make, A.who === B.who); +console.log(Object.getOwnPropertyNames(A).join(",")); +console.log(Object.getOwnPropertyNames(B).join(",")); +console.log(new A().kind, new B().kind); +console.log(new A() instanceof A, new A() instanceof B, new B() instanceof B); + +// A write on one never reaches the other. +(A as any).make = (x: number) => x - 100; +console.log(A.make(1), B.make(1)); +delete (B as any).who; +console.log(typeof A.who, typeof (B as any).who); + +// Block scopes, including one shadowing a module-level class of the same name. +class Pt { + static origin() { + return "outer"; + } + static z = 0; +} +{ + class Pt { + static origin() { + return "block1"; + } + static z = 1; + } + console.log(Pt.origin(), Pt.z); + class Sub extends Pt {} + console.log(Sub.origin(), Sub.z); +} +{ + class Pt { + static origin() { + return "block2:" + this.z; + } + static z = 2; + } + console.log(Pt.origin(), Pt.z); +} +console.log(Pt.origin(), Pt.z); + +// Static calls in loops through each same-named class (the static-call guard). +let s = 0; +for (let i = 0; i < 5; i++) s = A.make(s) + B.make(1); +console.log(s); From 960eb839de5c8e3ac1c80779ab45c08e6d935b4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 29 Sep 2026 06:48:10 +0000 Subject: [PATCH 06/11] perf(runtime): build Object and Object.prototype without the global object A declared class's prototype has %Object.prototype% as its [[Prototype]], and every route to it read globalThis.Object, so the first class materialization (a static call's guard miss mints the class function object) built the whole realm global: ~50M instructions for one pointer. ensure_object_intrinsics() now builds %Object% (statics, name, length, prototype) and %Object.prototype% (constructor and its methods) on their own, roots them with the other realm intrinsics, and memoizes the Object.prototype address row the moment it is built. The thread's realm global adopts the pair as globalThis.Object; a vm context or eval realm, which populate_global_this_builtins also fills, builds its own pair. The three readers (global_object_prototype_bits, default_object_prototype_bits and the prototype-address bootstrap) use the intrinsic. Defining a property on a function object asked for Function.prototype's descriptors through globalThis.Function, which built the realm global too; it now reads the realm's memoized %Function.prototype%, 0 while no realm global exists (then no descriptor can sit on it). First static call (scall n=1 minus n=0): 50.8M -> 1.09M instructions (base, which never mints, 0.14M). Zod: RSS 72.8 -> 70.7 MB (base 70.7), instructions -1.4%. --- changelog.d/object-intrinsics-standalone.md | 4 + crates/perry-runtime/src/array/mod.rs | 5 +- .../perry-runtime/src/array/prototype_addr.rs | 19 +- .../src/closure/dynamic_props.rs | 12 +- .../src/gc/tests/lazy_intrinsic_towers.rs | 2 +- .../src/object/class_registry/state.rs | 17 +- .../perry-runtime/src/object/global_this.rs | 6 +- .../src/object/global_this/fetch_globals.rs | 7 + .../object/global_this/object_intrinsic.rs | 190 ++++++++++++++++++ .../src/object/global_this/populate.rs | 20 +- crates/perry-runtime/src/object/mod.rs | 10 + .../src/object/prototype_chain.rs | 17 +- .../src/object/test_root_helpers.rs | 2 + scripts/gc_runtime_root_holders.json | 20 ++ 14 files changed, 285 insertions(+), 46 deletions(-) create mode 100644 changelog.d/object-intrinsics-standalone.md create mode 100644 crates/perry-runtime/src/object/global_this/object_intrinsic.rs diff --git a/changelog.d/object-intrinsics-standalone.md b/changelog.d/object-intrinsics-standalone.md new file mode 100644 index 0000000000..3fbc45036e --- /dev/null +++ b/changelog.d/object-intrinsics-standalone.md @@ -0,0 +1,4 @@ +Made the first use of a class cheaper: `Object` and `Object.prototype` are +built on their own (about 1M instructions) instead of by building the whole +global object (about 50M instructions, several hundred builtins); the global +object adopts the same two objects when it is built. diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index 88833d179c..3695b5356d 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -223,8 +223,9 @@ pub use self::numeric_range::{ }; pub use self::prototype_addr::scan_prototype_addr_cache_roots_mut; pub(crate) use self::prototype_addr::{ - array_prototype_addr, function_prototype_addr, object_prototype_addr, - object_prototype_addr_if_resolved, object_prototype_addr_matches, prime_prototype_addr_cache, + array_prototype_addr, function_prototype_addr, note_object_prototype_intrinsic, + object_prototype_addr, object_prototype_addr_if_resolved, object_prototype_addr_matches, + prime_prototype_addr_cache, }; #[cfg(test)] pub(crate) use self::prototype_addr::{ diff --git a/crates/perry-runtime/src/array/prototype_addr.rs b/crates/perry-runtime/src/array/prototype_addr.rs index 4504def4b9..d5f8fa2522 100644 --- a/crates/perry-runtime/src/array/prototype_addr.rs +++ b/crates/perry-runtime/src/array/prototype_addr.rs @@ -245,8 +245,10 @@ fn resolve_prototype_addr(slot: usize) -> usize { // and that read MATERIALIZES the realm global when this thread has none — // allocating the singleton and running `populate_global_this_builtins`, which // its own `[gc-globalthis-bootstrap]` diagnostic measures at ~5 ms. Before the - // realm global exists, neither intrinsic prototype object has been allocated, - // so NO address can be one of them and the honest answer is already known: + // realm global exists, Array.prototype and Function.prototype have not been + // allocated, and %Object.prototype% (which can exist without the realm + // global) memoized its row when it was built, so an unmemoized row here + // means NO address can be the intrinsic and the honest answer is already known: // "not resolved" (0), which every caller of these accessors handles because // `bootstrap_prototype_addr` can return it anyway. // @@ -276,6 +278,12 @@ fn resolve_prototype_addr(slot: usize) -> usize { #[cold] #[inline(never)] fn bootstrap_prototype_addr(slot: usize) -> usize { + if slot == OBJECT_PROTO_CACHE { + // %Object.prototype% is built on its own (`ensure_object_intrinsics`), + // which memoizes this row itself; 0 only while that build is running. + let (_, proto) = crate::object::ensure_object_intrinsics(); + return proto as usize; + } let builtin = PROTOTYPE_ADDR_BUILTINS[slot]; let ctor = crate::object::js_get_global_this_builtin_value(builtin.as_ptr(), builtin.len()); let ctor_value = crate::value::JSValue::from_bits(ctor.to_bits()); @@ -320,6 +328,13 @@ pub(crate) fn object_prototype_addr_if_resolved() -> usize { memoized_prototype_addr(&prototype_addrs()[OBJECT_PROTO_CACHE]).unwrap_or(0) } +/// Memoize THIS realm's `%Object.prototype%` the moment it is built, so the +/// store path's "is this Object.prototype?" check answers for it even before +/// the realm global exists (the class prototype chain reaches it first). +pub(crate) fn note_object_prototype_intrinsic(addr: usize) { + prototype_addrs()[OBJECT_PROTO_CACHE].set(addr); +} + /// **This realm's** `%Function.prototype%` address, or 0 while this thread /// has no `globalThis` yet (no intrinsic exists, so nothing can be it). See /// the row-2 note on [`prototype_addrs`] (#10497). diff --git a/crates/perry-runtime/src/closure/dynamic_props.rs b/crates/perry-runtime/src/closure/dynamic_props.rs index 76bed81da7..5f118e61ea 100644 --- a/crates/perry-runtime/src/closure/dynamic_props.rs +++ b/crates/perry-runtime/src/closure/dynamic_props.rs @@ -618,13 +618,13 @@ pub(crate) fn function_prototype_fallback_target(ptr: usize, prop: &str) -> Opti if reentrant { return None; } - let proto_val = crate::object::builtin_prototype_value("Function"); + // THIS realm's %Function.prototype% (the memoized intrinsic), not whatever + // `globalThis.Function` names now. It is 0 while the realm global has not + // been built: %Function.prototype% does not exist yet, so no descriptor + // can sit on it — and asking must not build the realm global (defining a + // static on %Object% or a class function object would otherwise do so). + let proto_ptr = crate::array::function_prototype_addr(); IN_FN_PROTO_FALLBACK.with(|c| c.set(false)); - let proto_jv = crate::value::JSValue::from_bits(proto_val.to_bits()); - if !proto_jv.is_pointer() { - return None; - } - let proto_ptr = (proto_jv.bits() & crate::value::POINTER_MASK) as usize; if proto_ptr == 0 || proto_ptr == ptr || is_closure_ptr(proto_ptr) { return None; } diff --git a/crates/perry-runtime/src/gc/tests/lazy_intrinsic_towers.rs b/crates/perry-runtime/src/gc/tests/lazy_intrinsic_towers.rs index 3fc8184d75..c4631fe3c2 100644 --- a/crates/perry-runtime/src/gc/tests/lazy_intrinsic_towers.rs +++ b/crates/perry-runtime/src/gc/tests/lazy_intrinsic_towers.rs @@ -253,7 +253,7 @@ fn realm_owned_intrinsic_module_and_storage_roots_are_distinct() { let a = a.join().expect("agent A panicked"); let b = b.join().expect("agent B panicked"); - assert_eq!(a.len(), 26, "the gate must cover every #8002/#8003 root"); + assert_eq!(a.len(), 28, "the gate must cover every #8002/#8003 root"); assert_eq!(a.len(), b.len()); for ((a_name, a_slot, a_root), (b_name, b_slot, b_root)) in a.iter().zip(&b) { assert_eq!(a_name, b_name, "snapshot wiring diverged between agents"); diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index f48a3219f2..43e120837e 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -1322,22 +1322,7 @@ pub(crate) fn class_decl_prototype_value_for_instance_class(class_id: u32) -> Op } pub(crate) fn global_object_prototype_bits() -> Option { - let object_ctor = js_get_global_this_builtin_value(b"Object".as_ptr(), 6); - let ctor_bits = object_ctor.to_bits(); - if (ctor_bits >> 48) != 0x7FFD { - return None; - } - let ctor_ptr = (ctor_bits & crate::value::POINTER_MASK) as usize; - if ctor_ptr == 0 { - return None; - } - let proto = crate::closure::closure_get_dynamic_prop(ctor_ptr, "prototype"); - let proto_bits = proto.to_bits(); - if (proto_bits >> 48) == 0x7FFD { - Some(proto_bits) - } else { - None - } + crate::object::object_prototype_intrinsic_bits() } #[cfg(test)] diff --git a/crates/perry-runtime/src/object/global_this.rs b/crates/perry-runtime/src/object/global_this.rs index 79c061d9e3..c87a9002b4 100644 --- a/crates/perry-runtime/src/object/global_this.rs +++ b/crates/perry-runtime/src/object/global_this.rs @@ -45,6 +45,7 @@ mod fetch_globals; mod generator; mod install_static; mod math_temporal; +mod object_intrinsic; mod populate; mod proto_methods; mod typed_array; @@ -143,7 +144,7 @@ pub(crate) use fetch_globals::{ global_this_date_thunk, global_this_eval_thunk, global_this_file_thunk, global_this_headers_thunk, global_this_is_materialized, global_this_request_thunk, global_this_response_error_thunk, global_this_response_json_thunk, - global_this_response_redirect_thunk, global_this_response_thunk, + global_this_response_redirect_thunk, global_this_response_thunk, is_thread_realm_global, }; pub use fetch_globals::{ js_fetch_or_value_super, js_get_global_this, js_global_or_console_property_by_name, @@ -174,6 +175,9 @@ pub(crate) use math_temporal::install_temporal_namespace; #[cfg(feature = "temporal")] pub(crate) use math_temporal::temporal_kind_prototype; pub(crate) use math_temporal::{install_math_namespace, temporal_ctor_kind}; +pub(crate) use object_intrinsic::{ + ensure_object_intrinsics, object_intrinsics_for_realm, object_prototype_intrinsic_bits, +}; pub(crate) use populate::{ default_prepare_stack_trace_func_ptr, populate_global_this_builtins, scan_error_constructor_root_mut, ERROR_CONSTRUCTOR_PTR, diff --git a/crates/perry-runtime/src/object/global_this/fetch_globals.rs b/crates/perry-runtime/src/object/global_this/fetch_globals.rs index 7aaa9cfbb8..e5275df40c 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -86,6 +86,13 @@ pub(crate) fn global_this_is_materialized() -> bool { THREAD_GLOBAL_THIS.with(|c| c.get()) != 0 } +/// Whether `obj` is THIS thread's realm global, as opposed to the global of a +/// `vm` context or an eval realm that `populate_global_this_builtins` also +/// fills (each of those gets its own intrinsics). +pub(crate) fn is_thread_realm_global(obj: *mut ObjectHeader) -> bool { + !obj.is_null() && THREAD_GLOBAL_THIS.with(|c| c.get()) == obj as i64 +} + /// Issue #611: lazily allocate `globalThis` for computed global access. #[no_mangle] pub extern "C" fn js_get_global_this() -> f64 { diff --git a/crates/perry-runtime/src/object/global_this/object_intrinsic.rs b/crates/perry-runtime/src/object/global_this/object_intrinsic.rs new file mode 100644 index 0000000000..9c5119c4d1 --- /dev/null +++ b/crates/perry-runtime/src/object/global_this/object_intrinsic.rs @@ -0,0 +1,190 @@ +use super::*; + +/// A realm's `%Object%` constructor and `%Object.prototype%`. +type ObjectPair = (*mut crate::closure::ClosureHeader, *mut ObjectHeader); + +crate::perry_thread_local! { + /// Set while THIS thread builds its `%Object%` / `%Object.prototype%`. + /// The build stores into the objects it creates, and the store path asks + /// "is this receiver %Object.prototype%?", which lands back in + /// [`ensure_object_intrinsics`]. That nested question has an honest answer + /// without building: the intrinsic does not exist yet, so nothing is it. + static OBJECT_INTRINSICS_BUILDING: std::sync::atomic::AtomicBool = + const { std::sync::atomic::AtomicBool::new(false) }; +} + +/// This realm's `%Object%` constructor and `%Object.prototype%`, built on +/// first use, complete: the constructor with its statics (`keys`, `create`, +/// `getPrototypeOf`, ...), `name`/`length`, and a non-writable `prototype`; +/// the prototype with `constructor` and its methods (`toString`, +/// `hasOwnProperty`, `__proto__`, ...). +/// +/// Nothing here reads `globalThis`. A declared class's prototype object has +/// `%Object.prototype%` as its `[[Prototype]]`, and so does every ordinary +/// object; reaching it through `globalThis.Object` made the first class +/// materialization build the whole realm global (several hundred builtins, +/// ~50M instructions) for one pointer. `populate_global_this_builtins` +/// ADOPTS these two objects as `globalThis.Object` / `Object.prototype`, so +/// there is exactly one of each per realm whichever side asks first. +/// +/// Both are rooted in `scan_object_cache_roots_mut` and the prototype's +/// address is memoized in the `Object.prototype` row of the per-thread +/// prototype-address cache, like the other realm intrinsics. +/// +/// Returns null pointers only while this thread is inside the build itself +/// (see [`OBJECT_INTRINSICS_BUILDING`]) or if an allocation failed. +pub(crate) fn ensure_object_intrinsics() -> ObjectPair { + let loaded = || { + ( + crate::object::OBJECT_INTRINSIC_PTR.load(Ordering::Acquire), + crate::object::OBJECT_INTRINSIC_PROTO_PTR.load(Ordering::Acquire), + ) + }; + let (ctor, proto) = loaded(); + if ctor != 0 && proto != 0 { + return (ctor as *mut _, proto as *mut _); + } + if OBJECT_INTRINSICS_BUILDING.with(|b| b.swap(true, Ordering::AcqRel)) { + return (std::ptr::null_mut(), std::ptr::null_mut()); + } + let built = build_object_intrinsics(); + if let Some((ctor, proto)) = built { + crate::object::OBJECT_INTRINSIC_PTR.store(ctor as i64, Ordering::Release); + crate::object::OBJECT_INTRINSIC_PROTO_PTR.store(proto as i64, Ordering::Release); + crate::array::note_object_prototype_intrinsic(proto as usize); + } + OBJECT_INTRINSICS_BUILDING.with(|b| b.store(false, Ordering::Release)); + built.unwrap_or((std::ptr::null_mut(), std::ptr::null_mut())) +} + +/// The `%Object%` / `%Object.prototype%` pair for the realm whose global is +/// `global`: this thread's own pair (see [`ensure_object_intrinsics`]) when +/// `global` is the thread's realm global, a fresh pair for any other realm +/// (a `vm` context or an eval realm), whose intrinsics are its own. +pub(crate) fn object_intrinsics_for_realm(global: *mut ObjectHeader) -> ObjectPair { + if is_thread_realm_global(global) { + return ensure_object_intrinsics(); + } + build_object_intrinsics().unwrap_or((std::ptr::null_mut(), std::ptr::null_mut())) +} + +/// `%Object.prototype%` as NaN-boxed pointer bits, built on first use. +pub(crate) fn object_prototype_intrinsic_bits() -> Option { + let (_, proto) = ensure_object_intrinsics(); + (!proto.is_null()).then(|| crate::value::js_nanbox_pointer(proto as i64).to_bits()) +} + +fn build_object_intrinsics() -> Option { + // The same no-move window and immortal-layout scope as the realm + // bootstrap (see `populate_global_this_builtins`): the constructor and + // prototype are held as raw pointers across every allocating install + // below, and both live for the life of the realm. + let _no_move = crate::gc::GcSuppressScope::new(); + let _immortal = crate::gc::ImmortalLayoutScope::new(); + let func_ptr = global_this_object_thunk as *const u8; + let closure_ptr = crate::closure::js_closure_alloc(func_ptr, 0); + if closure_ptr.is_null() { + return None; + } + crate::closure::js_register_closure_arity(func_ptr, 1); + install_builtin_constructor_statics("Object", closure_ptr); + super::super::native_module::set_bound_native_closure_name(closure_ptr, "Object"); + if let Some(len) = builtin_constructor_spec_length("Object") { + super::super::native_module::set_builtin_closure_length(closure_ptr as usize, len); + } + for key in ["name", "length"] { + super::super::set_builtin_property_attrs( + closure_ptr as usize, + key.to_string(), + super::super::PropertyAttrs::new(false, false, true), + ); + } + let proto_obj = js_object_alloc(0, 0); + if proto_obj.is_null() { + return None; + } + let ctor_value = crate::value::js_nanbox_pointer(closure_ptr as i64); + let proto_key = crate::string::js_string_from_bytes(b"prototype".as_ptr(), 9); + super::super::define_builtin_data_property( + closure_ptr as *mut ObjectHeader, + proto_key, + crate::value::js_nanbox_pointer(proto_obj as i64), + "prototype".to_string(), + super::super::PropertyAttrs::new(false, false, false), + ); + let ctor_key = crate::string::js_string_from_bytes(b"constructor".as_ptr(), 11); + super::super::define_builtin_data_property( + proto_obj, + ctor_key, + ctor_value, + "constructor".to_string(), + super::super::PropertyAttrs::new(true, false, true), + ); + populate_builtin_prototype_methods("Object", proto_obj); + Some((closure_ptr, proto_obj)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn on_a_fresh_thread(body: impl FnOnce() + Send + 'static) { + std::thread::Builder::new() + .stack_size(16 << 20) + .spawn(body) + .expect("spawn object-intrinsic test thread") + .join() + .expect("object-intrinsic test thread panicked"); + } + + /// A declared class's prototype reaches %Object.prototype% without + /// building the realm global, and the realm global then adopts the very + /// same %Object% / %Object.prototype% rather than building a second pair. + #[test] + fn object_prototype_is_built_without_the_realm_global_and_adopted_by_it() { + on_a_fresh_thread(|| { + assert!(!crate::object::global_this_is_materialized()); + let class_parent = crate::object::class_registry::global_object_prototype_bits() + .expect("%Object.prototype% for a class prototype"); + let default_parent = crate::object::prototype_chain::default_object_prototype_bits() + .expect("%Object.prototype% for an ordinary object"); + assert_eq!(class_parent, default_parent); + assert!( + !crate::object::global_this_is_materialized(), + "reaching %Object.prototype% built the realm global" + ); + let (ctor, proto) = ensure_object_intrinsics(); + let proto_addr = proto as usize; + assert_eq!( + crate::array::object_prototype_addr_if_resolved(), + proto_addr + ); + assert!(crate::array::object_prototype_addr_matches(proto_addr)); + // Complete before any realm global: statics, prototype methods. + let keys = crate::closure::closure_get_dynamic_prop(ctor as usize, "keys"); + assert!( + JSValue::from_bits(keys.to_bits()).is_pointer(), + "Object.keys missing" + ); + let has_own = crate::string::js_string_from_bytes(b"hasOwnProperty".as_ptr(), 14); + let method = js_object_get_field_by_name(proto, has_own); + assert!(method.is_pointer(), "hasOwnProperty missing"); + + crate::object::js_get_global_this(); + let global_object = js_get_global_this_builtin_value(b"Object".as_ptr(), 6); + let (ctor_now, proto_now) = ensure_object_intrinsics(); + assert_eq!( + global_object.to_bits(), + crate::value::js_nanbox_pointer(ctor_now as i64).to_bits(), + "globalThis.Object is not the intrinsic" + ); + let global_proto = + crate::closure::closure_get_dynamic_prop(ctor_now as usize, "prototype"); + assert_eq!( + global_proto.to_bits(), + crate::value::js_nanbox_pointer(proto_now as i64).to_bits() + ); + assert_eq!(crate::array::object_prototype_addr(), proto_now as usize); + }); + } +} diff --git a/crates/perry-runtime/src/object/global_this/populate.rs b/crates/perry-runtime/src/object/global_this/populate.rs index c64501e709..39cb5b5acc 100644 --- a/crates/perry-runtime/src/object/global_this/populate.rs +++ b/crates/perry-runtime/src/object/global_this/populate.rs @@ -193,12 +193,28 @@ pub(crate) fn populate_global_this_builtins(singleton_at_entry: *mut ObjectHeade ); continue; } + if name == "Object" { + // %Object% / %Object.prototype% are built on their own (a class + // prototype needs them without the realm global); the thread's + // realm global adopts them, a `vm`/eval realm gets its own pair. + let (object_ctor, _) = object_intrinsics_for_realm(singleton()); + if !object_ctor.is_null() { + let name_key = crate::string::js_string_from_bytes(b"Object".as_ptr(), 6); + super::super::define_builtin_data_property( + singleton(), + name_key, + crate::value::js_nanbox_pointer(object_ctor as i64), + name.to_string(), + super::super::PropertyAttrs::new(true, false, true), + ); + } + continue; + } let func_ptr = match name { "Array" => global_this_array_thunk as *const u8, // #10423: `F(p, body)` through a `Function` value creates a // function, exactly like `new F(p, body)`. "Function" => global_this_function_call_thunk as *const u8, - "Object" => global_this_object_thunk as *const u8, "String" => global_this_string_thunk as *const u8, // #2889: call-form `Number(x)` / `Boolean(x)` through a rebound // global value coerce like the bare-call lowering does. @@ -256,7 +272,7 @@ pub(crate) fn populate_global_this_builtins(singleton_at_entry: *mut ObjectHeade "Date" => { crate::closure::js_register_closure_arity(func_ptr, 1); } - "Object" | "String" | "Number" | "Boolean" | "BroadcastChannel" => { + "String" | "Number" | "Boolean" | "BroadcastChannel" => { crate::closure::js_register_closure_arity(func_ptr, 1); } "Headers" => { diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index f47c761158..0068db88a1 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -470,6 +470,8 @@ crate::perry_thread_local! { static LOCAL_STORAGE_PTR_SLOT: AtomicI64 = const { AtomicI64::new(0) }; static SESSION_STORAGE_PTR_SLOT: AtomicI64 = const { AtomicI64::new(0) }; static URL_INTRINSIC_PROTO_PTR_SLOT: AtomicI64 = const { AtomicI64::new(0) }; + static OBJECT_INTRINSIC_PTR_SLOT: AtomicI64 = const { AtomicI64::new(0) }; + static OBJECT_INTRINSIC_PROTO_PTR_SLOT: AtomicI64 = const { AtomicI64::new(0) }; } static HTTP_METHODS_CACHE: RealmAtomicU64 = RealmAtomicU64::new(&HTTP_METHODS_CACHE_SLOT); @@ -511,6 +513,12 @@ pub(crate) static ASYNC_GENERATOR_PROTOTYPE_PTR: RealmAtomicI64 = /// native constructor builds must keep the real component accessors (#11585). pub(crate) static URL_INTRINSIC_PROTO_PTR: RealmAtomicI64 = RealmAtomicI64::new(&URL_INTRINSIC_PROTO_PTR_SLOT); +/// `%Object%` and `%Object.prototype%`, built by `ensure_object_intrinsics` +/// without the realm global and adopted by it. +pub(crate) static OBJECT_INTRINSIC_PTR: RealmAtomicI64 = + RealmAtomicI64::new(&OBJECT_INTRINSIC_PTR_SLOT); +pub(crate) static OBJECT_INTRINSIC_PROTO_PTR: RealmAtomicI64 = + RealmAtomicI64::new(&OBJECT_INTRINSIC_PROTO_PTR_SLOT); pub(crate) static LOCAL_STORAGE_PTR: RealmAtomicI64 = RealmAtomicI64::new(&LOCAL_STORAGE_PTR_SLOT); pub(crate) static SESSION_STORAGE_PTR: RealmAtomicI64 = RealmAtomicI64::new(&SESSION_STORAGE_PTR_SLOT); @@ -1500,6 +1508,8 @@ pub fn scan_object_cache_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<' &LOCAL_STORAGE_PTR, &SESSION_STORAGE_PTR, &URL_INTRINSIC_PROTO_PTR, + &OBJECT_INTRINSIC_PTR, + &OBJECT_INTRINSIC_PROTO_PTR, ] { slot.with_slot(|slot| { visitor.visit_atomic_i64_slot(slot, Ordering::Acquire, Ordering::Release); diff --git a/crates/perry-runtime/src/object/prototype_chain.rs b/crates/perry-runtime/src/object/prototype_chain.rs index 4ffc11b31f..71aa243f7b 100644 --- a/crates/perry-runtime/src/object/prototype_chain.rs +++ b/crates/perry-runtime/src/object/prototype_chain.rs @@ -872,22 +872,7 @@ pub(crate) fn class_default_prototype_superseded(obj_ptr: usize) -> bool { } pub(crate) fn default_object_prototype_bits() -> Option { - let object_ctor = super::js_get_global_this_builtin_value(b"Object".as_ptr(), 6); - let ctor_bits = object_ctor.to_bits(); - if (ctor_bits >> 48) != 0x7FFD { - return None; - } - let ctor_ptr = (ctor_bits & crate::value::POINTER_MASK) as usize; - if ctor_ptr == 0 { - return None; - } - let proto = crate::closure::closure_get_dynamic_prop(ctor_ptr, "prototype"); - let proto_bits = proto.to_bits(); - if (proto_bits >> 48) == 0x7FFD { - Some(proto_bits) - } else { - None - } + crate::object::object_prototype_intrinsic_bits() } pub(crate) unsafe fn default_object_prototype_for_owner(obj_ptr: usize) -> Option { diff --git a/crates/perry-runtime/src/object/test_root_helpers.rs b/crates/perry-runtime/src/object/test_root_helpers.rs index fdc441ad0d..7dde1e66b4 100644 --- a/crates/perry-runtime/src/object/test_root_helpers.rs +++ b/crates/perry-runtime/src/object/test_root_helpers.rs @@ -166,6 +166,8 @@ pub(crate) fn test_realm_owned_root_snapshot() -> Vec<(&'static str, usize, u64) "ASYNC_GENERATOR_PROTOTYPE_PTR", &ASYNC_GENERATOR_PROTOTYPE_PTR, ), + ("OBJECT_INTRINSIC_PTR", &OBJECT_INTRINSIC_PTR), + ("OBJECT_INTRINSIC_PROTO_PTR", &OBJECT_INTRINSIC_PROTO_PTR), ("LOCAL_STORAGE_PTR", &LOCAL_STORAGE_PTR), ("SESSION_STORAGE_PTR", &SESSION_STORAGE_PTR), ( diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index bce6b4ef18..c04126bfa1 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -2765,6 +2765,26 @@ "name": "CLASS_SHAPE", "verdict": "not_a_gc_pointer", "why": "This agent's class-constructor ShapeId (a u32 shape-directory index minted once and pinned as an external shape carrier), never a heap reference." + }, + { + "file": "crates/perry-runtime/src/object/global_this/object_intrinsic.rs", + "name": "OBJECT_INTRINSICS_BUILDING", + "verdict": "not_a_gc_pointer", + "why": "A bool: set while this thread builds its %Object% / %Object.prototype% pair, so the build's own stores do not re-enter the build. No address is ever stored in it." + }, + { + "file": "crates/perry-runtime/src/object/mod.rs", + "name": "OBJECT_INTRINSIC_PTR_SLOT", + "verdict": "covered_elsewhere", + "scanner": "object::scan_object_cache_roots_mut (crates/perry-runtime/src/object/mod.rs), through the RealmAtomicI64 wrapper OBJECT_INTRINSIC_PTR", + "why": "This realm's %Object% constructor (a ClosureHeader), built by ensure_object_intrinsics and adopted as globalThis.Object. The registered object-cache scanner visits the slot through its RealmAtomicI64 wrapper, whose name differs from the thread-local's, so the same-file rule cannot match it." + }, + { + "file": "crates/perry-runtime/src/object/mod.rs", + "name": "OBJECT_INTRINSIC_PROTO_PTR_SLOT", + "verdict": "covered_elsewhere", + "scanner": "object::scan_object_cache_roots_mut (crates/perry-runtime/src/object/mod.rs), through the RealmAtomicI64 wrapper OBJECT_INTRINSIC_PROTO_PTR", + "why": "This realm's %Object.prototype%, built by ensure_object_intrinsics and adopted as globalThis.Object.prototype. The registered object-cache scanner visits the slot through its RealmAtomicI64 wrapper, whose name differs from the thread-local's, so the same-file rule cannot match it. Its address is also memoized in the prototype-address cache row, which scan_prototype_addr_cache_roots_mut rewrites." } ], "_FRONTIER_README": "Identity-pinned debt ratchet over new perry-ui* candidates and otherwise-unclassified core raw/Perry TLS declarations (see the census docstring, \u201cThe identity-pinned frontier\u201d). A new uncovered holder fails until it is scanned, receives a researched holders verdict, or is deliberately pinned as debt. Moving a researched false positive to holders graduates it from this list. A fixed or classified holder makes its old frontier pin stale, so the receipt must be deleted.", From b991360306f2d60734c4cfc1ec8f858a7551e488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 29 Sep 2026 06:48:18 +0000 Subject: [PATCH 07/11] test: an inherited static call sees a store to the parent static A static-call site on a subclass memoizes the subclass and the declaring parent. Storing the parent static transitions only the parent shape, and the subclass shape stays the same, so the parent shape check is what sends the call to the stored function. The loop bound is not a constant, so the site is one site, armed on the first call and hit on the following ones (a constant three-iteration loop unrolls into three sites that each run once and never hit). --- .../test_gap_class_static_inherited_store.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 test-files/test_gap_class_static_inherited_store.ts diff --git a/test-files/test_gap_class_static_inherited_store.ts b/test-files/test_gap_class_static_inherited_store.ts new file mode 100644 index 0000000000..33ff871af6 --- /dev/null +++ b/test-files/test_gap_class_static_inherited_store.ts @@ -0,0 +1,20 @@ +// A static-call site on a subclass (`S.m()` where only the parent R declares +// `m`) memoizes BOTH classes' shapes: storing `R.m` transitions R's shape and +// leaves S's alone, so a guard that checked only S would keep calling the old +// body. The loop bound is not a compile-time constant, so the site is one +// site that is armed on the first call and hit on the next ones. +class R { + static m() { + return "R.m"; + } +} +class S extends R {} +class T extends S {} +const n = process.argv.length + 5; +const out: string[] = []; +for (let i = 0; i < n; i++) { + if (i === 3) (R as any).m = function () { return "stored"; }; + if (i === 5) (S as any).m = function () { return "S.own"; }; + out.push(S.m() + "/" + T.m()); +} +console.log(out.join(",")); From fe942969866739a5a056de6ba288540183c540a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 29 Sep 2026 10:45:16 +0000 Subject: [PATCH 08/11] perf(runtime): class static member names hash with ahash, not SipHash The inner name-keyed maps of the static method and static accessor tables used std SipHash. A static call that misses its site guard probes the map once per class on the parent chain, and SipHash showed up in the tsc profile. The names come from program source, so the maps keep a randomly keyed, flood-resistant hasher: ahash::RandomState, the one the runtime already uses for untrusted string keys (json::parser). --- changelog.d/class-static-names-fast-hash.md | 3 +++ .../perry-runtime/src/object/class_image.rs | 26 ++++++++++++------- 2 files changed, 19 insertions(+), 10 deletions(-) create mode 100644 changelog.d/class-static-names-fast-hash.md diff --git a/changelog.d/class-static-names-fast-hash.md b/changelog.d/class-static-names-fast-hash.md new file mode 100644 index 0000000000..7da06ea044 --- /dev/null +++ b/changelog.d/class-static-names-fast-hash.md @@ -0,0 +1,3 @@ +Class static method and accessor lookups by name hash with ahash instead of +SipHash, so a static call that misses its site guard probes each class on the +parent chain faster. diff --git a/crates/perry-runtime/src/object/class_image.rs b/crates/perry-runtime/src/object/class_image.rs index e635bce06b..14644ad93f 100644 --- a/crates/perry-runtime/src/object/class_image.rs +++ b/crates/perry-runtime/src/object/class_image.rs @@ -86,14 +86,19 @@ pub(crate) const PARENT_DENSE_CAP: usize = 1 << 16; /// class_id -> { name -> (func_ptr, param_count, has_rest) } for static methods. /// -/// OUTER map only takes the fast hasher (see `ClassImageTables`); the INNER -/// `HashMap` stays on SipHash because its keys are JS-supplied -/// member names. +/// OUTER map takes the fast pointer hasher (see `ClassImageTables`); the +/// INNER map is a [`StaticNameMap`], keyed by member name. /// (body func_ptr, param_count, has_rest, closure-convention entry or 0). -pub type StaticMethodTable = PtrHashMap>; +pub type StaticMethodTable = PtrHashMap>; /// class_id -> { name -> (getter func_ptr, setter func_ptr) } for static accessors. -/// Outer map fast-hashed, inner `String`-keyed map deliberately not — see above. -pub type StaticAccessorTable = PtrHashMap>; +/// Outer map fast-hashed, inner map a [`StaticNameMap`] — see above. +pub type StaticAccessorTable = PtrHashMap>; +/// A class's static members by name. The names come from program source, so +/// the map keeps a randomly keyed, flood-resistant hasher: `ahash::RandomState`, +/// the runtime's hasher for untrusted string keys (as in `json::parser`), in +/// place of std's slower SipHash. A static call that misses its site guard +/// probes this map once per class on the parent chain. +pub type StaticNameMap = HashMap; /// `(class_id, is_static, property_name) -> source-order token` for declared /// string-keyed methods and accessors. The token is the member function's HIR /// id, which is allocated while walking the class body and therefore orders @@ -120,10 +125,11 @@ pub type ConstructorFlagTable = PtrHashMap; /// /// Iteration order is not observable for any of these: nothing in the tree /// iterates them (only `get` / `insert` / `contains`). The `String`-keyed -/// INNER maps of [`StaticMethodTable`] / [`StaticAccessorTable`], and the -/// `(u32, String)`-keyed `method_bind_lengths` pair below, deliberately stay -/// on SipHash — their keys are JS-supplied member names, and the inner maps -/// are enumerated on paths that reach user-visible output. +/// INNER maps of [`StaticMethodTable`] / [`StaticAccessorTable`] keep a +/// randomly keyed hasher ([`StaticNameMap`]), and the `(u32, String)`-keyed +/// `method_bind_lengths` pair below stays on SipHash: their keys are +/// JS-supplied member names. The inner maps are enumerated on paths that +/// reach user-visible output, so no caller may rely on their order. pub struct ClassImageTables { pub(crate) vtables: RwLock>>, pub(crate) static_methods: RwLock>, From 6df4067be0ae925a94089dfeb82e98916185bf2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 29 Sep 2026 11:34:54 +0000 Subject: [PATCH 09/11] fix(runtime): a static accessor entry is never called with the receiver as a parameter A ClassBody static accessor's compiled entry takes no receiver (fn() / fn(v), this armed by the caller); an instance accessor's takes it as a parameter (fn(this) / fn(this, v)). The class function object's accessor pairs stored static entries in the same raw_get/raw_set fields as instance entries, so the inherited-access table, which calls raw_set as fn(this, v), handed a static setter the class as its value: every generic static store (C.x = v, an inherited static setter, Reflect.set) set the class instead of v. The reflected setter closure had the same mismatch (Object.getOwnPropertyDescriptor(C, x).set.call(C, v), also on main). The pair's raw word now says which convention its entry has: a static entry carries STATIC_ENTRY_BIT above the address, and decoding splits the two into raw_* (instance) and static_* (static) fields, so an instance-convention reader is never handed a static entry. Static pairs are built with static_* only; reflected static accessors wrap the entry in static thunks that arm this and the private owner as a direct static access does. --- .../class-static-accessor-entry-convention.md | 4 + .../perry-runtime/src/object/accessor_pair.rs | 73 ++++++++++++++---- .../src/object/accessor_pair_tests.rs | 2 + .../object/class_registry/decl_accessors.rs | 4 +- .../parent_static/private_and_dynamic.rs | 2 + .../src/object/class_registry/registration.rs | 54 ++++++++++++- .../perry-runtime/src/object/class_value.rs | 28 ++++--- .../src/object/descriptors/builders.rs | 14 +++- .../src/object/inherited_read_cache_tests.rs | 2 +- ..._gap_class_static_setter_receives_value.ts | 75 +++++++++++++++++++ 10 files changed, 223 insertions(+), 35 deletions(-) create mode 100644 changelog.d/class-static-accessor-entry-convention.md create mode 100644 test-files/test_gap_class_static_setter_receives_value.ts diff --git a/changelog.d/class-static-accessor-entry-convention.md b/changelog.d/class-static-accessor-entry-convention.md new file mode 100644 index 0000000000..5469d328f5 --- /dev/null +++ b/changelog.d/class-static-accessor-entry-convention.md @@ -0,0 +1,4 @@ +Fixed a class static setter receiving the class instead of the assigned value +on the generic property path (`C.x = v`, directly, through a variable or on a +subclass, and `Reflect.set`) and through the reflected setter function +(`Object.getOwnPropertyDescriptor(C, "x").set`), string- and symbol-keyed. diff --git a/crates/perry-runtime/src/object/accessor_pair.rs b/crates/perry-runtime/src/object/accessor_pair.rs index 3eff999d99..9689ad72c0 100644 --- a/crates/perry-runtime/src/object/accessor_pair.rs +++ b/crates/perry-runtime/src/object/accessor_pair.rs @@ -15,8 +15,18 @@ //! |---|---| //! | [`PAIR_GET`] | the getter as a NaN-boxed closure, or `undefined` | //! | [`PAIR_SET`] | the setter as a NaN-boxed closure, or `undefined` | -//! | [`PAIR_RAW_GET`] | a class getter's compiled entry `fn(this) -> value` (its address bits), or 0 | -//! | [`PAIR_RAW_SET`] | a class setter's compiled entry `fn(this, v) -> value` (its address bits), or 0 | +//! | [`PAIR_RAW_GET`] | a class getter's compiled entry (its address bits, see below), or 0 | +//! | [`PAIR_RAW_SET`] | a class setter's compiled entry (its address bits, see below), or 0 | +//! +//! A compiled entry has one of two calling conventions, and the word says +//! which. An INSTANCE accessor's entry takes the receiver as a parameter +//! (`fn(this) -> value` / `fn(this, v)`); its word is the bare address. A +//! STATIC accessor's entry (a ClassBody `static get`/`static set`, installed +//! on the class function object) takes no receiver (`fn() -> value` / +//! `fn(v)`, `this` armed by the caller); its word carries +//! [`STATIC_ENTRY_BIT`] above the address. Decoding splits the two into +//! different [`Accessor`] fields, so a reader that calls with the receiver +//! as a parameter (`raw_get`/`raw_set`) is never handed a static entry. //! //! The two closure words are ordinary traced slots. The raw entries are code //! addresses stored as their plain bits: an address below 2^48 has none of the @@ -52,26 +62,44 @@ pub(crate) struct Accessor { pub get: u64, /// NaN-boxed setter closure bits, 0 when absent. pub set: u64, - /// A class getter's compiled entry, 0 when absent. + /// An instance class getter's compiled entry `fn(this) -> value`, 0 when + /// absent. pub raw_get: usize, - /// A class setter's compiled entry, 0 when absent. + /// An instance class setter's compiled entry `fn(this, v)`, 0 when absent. pub raw_set: usize, + /// A static class getter's compiled entry `fn() -> value` (`this` armed + /// by the caller), 0 when absent. Never set together with `raw_get`. + pub static_get: usize, + /// A static class setter's compiled entry `fn(v)` (`this` armed by the + /// caller), 0 when absent. Never set together with `raw_set`. + pub static_set: usize, } /// Largest code address a raw word can hold: below it no NaN-box tag bit is /// set, so the word is a Number to the collector. const RAW_ADDRESS_LIMIT: u64 = 1 << 48; +/// Marks a raw word's entry as a STATIC accessor's (`fn()` / `fn(v)`, no +/// receiver parameter). It sits just above the address bits and below every +/// NaN-box tag bit, so the word is still a Number to the collector. +pub(crate) const STATIC_ENTRY_BIT: u64 = RAW_ADDRESS_LIMIT; + +/// The word for one half: its instance entry `raw`, or its static entry +/// `stat` tagged with [`STATIC_ENTRY_BIT`]; a half has at most one. #[inline] -fn raw_word(raw: usize) -> u64 { - debug_assert!((raw as u64) < RAW_ADDRESS_LIMIT); - if (raw as u64) < RAW_ADDRESS_LIMIT { +fn raw_word(raw: usize, stat: usize) -> u64 { + debug_assert!(raw == 0 || stat == 0); + debug_assert!((raw as u64) < RAW_ADDRESS_LIMIT && (stat as u64) < RAW_ADDRESS_LIMIT); + if raw != 0 && (raw as u64) < RAW_ADDRESS_LIMIT { raw as u64 + } else if stat != 0 && (stat as u64) < RAW_ADDRESS_LIMIT { + stat as u64 | STATIC_ENTRY_BIT } else { 0 } } +/// A raw word's INSTANCE entry, 0 when it holds none (or a static one). #[inline] fn raw_of(word: u64) -> usize { if word < RAW_ADDRESS_LIMIT { @@ -81,6 +109,16 @@ fn raw_of(word: u64) -> usize { } } +/// A raw word's STATIC entry, 0 when it holds none (or an instance one). +#[inline] +fn static_of(word: u64) -> usize { + if word & !(RAW_ADDRESS_LIMIT - 1) == STATIC_ENTRY_BIT { + (word & (RAW_ADDRESS_LIMIT - 1)) as usize + } else { + 0 + } +} + /// The accessor a pair VALUE holds, without re-proving that it is one — for a /// cache hit whose entry proved it at prime time (the holder's key is an /// accessor, and a slot of an accessor key is written only by an accessor @@ -91,11 +129,14 @@ fn raw_of(word: u64) -> usize { #[inline(always)] pub(crate) unsafe fn pair_of_value_unchecked(value: u64) -> Accessor { let w = crate::array::array_elements_ptr((value & POINTER_MASK) as *const ArrayHeader); + let (raw_get_word, raw_set_word) = (*w.add(PAIR_RAW_GET), *w.add(PAIR_RAW_SET)); Accessor { get: closure_of(*w.add(PAIR_GET)), set: closure_of(*w.add(PAIR_SET)), - raw_get: raw_of(*w.add(PAIR_RAW_GET)), - raw_set: raw_of(*w.add(PAIR_RAW_SET)), + raw_get: raw_of(raw_get_word), + raw_set: raw_of(raw_set_word), + static_get: static_of(raw_get_word), + static_set: static_of(raw_set_word), } } @@ -131,8 +172,8 @@ pub(crate) unsafe fn pair_new(acc: Accessor) -> *mut ArrayHeader { // the slots below, after `length` covers them, before anything can read it. *w.add(PAIR_GET) = get.get_nanbox_u64(); *w.add(PAIR_SET) = set.get_nanbox_u64(); - *w.add(PAIR_RAW_GET) = raw_word(acc.raw_get); - *w.add(PAIR_RAW_SET) = raw_word(acc.raw_set); + *w.add(PAIR_RAW_GET) = raw_word(acc.raw_get, acc.static_get); + *w.add(PAIR_RAW_SET) = raw_word(acc.raw_set, acc.static_set); (*pair).length = PAIR_LEN as u32; crate::object::gc_slots::rebuild_array_layout_from_slots(pair); if crate::arena::pointer_in_old_gen(pair as usize) { @@ -160,11 +201,14 @@ pub(crate) unsafe fn pair_of_value(value: u64) -> Option { return None; } let w = crate::array::array_elements_ptr(pair); + let (raw_get_word, raw_set_word) = (*w.add(PAIR_RAW_GET), *w.add(PAIR_RAW_SET)); Some(Accessor { get: closure_of(*w.add(PAIR_GET)), set: closure_of(*w.add(PAIR_SET)), - raw_get: raw_of(*w.add(PAIR_RAW_GET)), - raw_set: raw_of(*w.add(PAIR_RAW_SET)), + raw_get: raw_of(raw_get_word), + raw_set: raw_of(raw_set_word), + static_get: static_of(raw_get_word), + static_set: static_of(raw_set_word), }) } @@ -220,8 +264,7 @@ pub(crate) fn pair_from(acc: &crate::object::AccessorDescriptor) -> Accessor { Accessor { get: acc.get, set: acc.set, - raw_get: 0, - raw_set: 0, + ..Accessor::default() } } diff --git a/crates/perry-runtime/src/object/accessor_pair_tests.rs b/crates/perry-runtime/src/object/accessor_pair_tests.rs index 87e84f1835..066491694e 100644 --- a/crates/perry-runtime/src/object/accessor_pair_tests.rs +++ b/crates/perry-runtime/src/object/accessor_pair_tests.rs @@ -24,6 +24,8 @@ fn a_pair_round_trips_both_forms() { set: 0, raw_get: 0x5555_1234_5678, raw_set: 0, + static_get: 0, + static_set: 0x5555_8765_4320, }; let pair = pair_new(acc); let value = crate::value::js_nanbox_pointer(pair as i64).to_bits(); diff --git a/crates/perry-runtime/src/object/class_registry/decl_accessors.rs b/crates/perry-runtime/src/object/class_registry/decl_accessors.rs index f745277544..eab694a150 100644 --- a/crates/perry-runtime/src/object/class_registry/decl_accessors.rs +++ b/crates/perry-runtime/src/object/class_registry/decl_accessors.rs @@ -52,7 +52,7 @@ pub(crate) fn install_decl_prototype_accessor(proto: *mut ObjectHeader, class_id } else if raw == have_raw && have != 0 { have } else { - class_accessor_function_value(raw, is_setter, name).to_bits() + class_accessor_function_value(raw, is_setter, false, name).to_bits() } }; let get = scope.root_nanbox_u64(half(raw_get, existing.raw_get, existing.get, false)); @@ -62,6 +62,8 @@ pub(crate) fn install_decl_prototype_accessor(proto: *mut ObjectHeader, class_id set, raw_get, raw_set, + static_get: 0, + static_set: 0, }; proto_h.with_mut_ptr(|p: *mut ObjectHeader| { crate::object::set_builtin_accessor_pair( diff --git a/crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs b/crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs index 5850ee93bd..454e02b828 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs @@ -126,6 +126,8 @@ pub(crate) fn register_class_dynamic_static_accessor( set: set_bits.map(|_| set.get_nanbox_u64()).unwrap_or(have.set), raw_get: if get_bits.is_some() { 0 } else { have.raw_get }, raw_set: if set_bits.is_some() { 0 } else { have.raw_set }, + static_get: if get_bits.is_some() { 0 } else { have.static_get }, + static_set: if set_bits.is_some() { 0 } else { have.static_set }, }; let enumerable = enumerable .or(existing.map(|(_, e, _)| e)) diff --git a/crates/perry-runtime/src/object/class_registry/registration.rs b/crates/perry-runtime/src/object/class_registry/registration.rs index 4ab6a4c3eb..09f23e778b 100644 --- a/crates/perry-runtime/src/object/class_registry/registration.rs +++ b/crates/perry-runtime/src/object/class_registry/registration.rs @@ -222,6 +222,46 @@ extern "C" fn class_accessor_getter_thunk(closure: *const crate::closure::Closur f(this) } +/// Trampoline for a raw STATIC getter func_ptr (`fn() -> f64`): the closure +/// call's `this` is the class the getter runs on, armed as its static `this` +/// and its private/capture owner exactly as a direct static access arms them. +extern "C" fn class_static_accessor_getter_thunk( + closure: *const crate::closure::ClosureHeader, +) -> f64 { + let raw = crate::closure::js_closure_get_capture_ptr(closure, 0) as usize; + if raw == 0 { + return f64::from_bits(crate::value::TAG_UNDEFINED); + } + let this = crate::object::js_implicit_this_get(); + crate::object::static_this_arm_if_unarmed(this); + crate::object::static_private_owner_push(this); + let f: extern "C" fn() -> f64 = unsafe { std::mem::transmute(raw) }; + let result = f(); + crate::object::static_private_owner_pop(); + crate::object::static_this_disarm(); + result +} + +/// Trampoline for a raw STATIC setter func_ptr (`fn(value) -> f64`): the +/// value is its only parameter; `this` is armed as for the getter. +extern "C" fn class_static_accessor_setter_thunk( + closure: *const crate::closure::ClosureHeader, + value: f64, +) -> f64 { + let raw = crate::closure::js_closure_get_capture_ptr(closure, 0) as usize; + if raw == 0 { + return f64::from_bits(crate::value::TAG_UNDEFINED); + } + let this = crate::object::js_implicit_this_get(); + crate::object::static_this_arm_if_unarmed(this); + crate::object::static_private_owner_push(this); + let f: extern "C" fn(f64) -> f64 = unsafe { std::mem::transmute(raw) }; + let result = f(value); + crate::object::static_private_owner_pop(); + crate::object::static_this_disarm(); + result +} + /// Trampoline for a raw vtable setter func_ptr (`fn(this, value) -> f64`). extern "C" fn class_accessor_setter_thunk( closure: *const crate::closure::ClosureHeader, @@ -248,6 +288,8 @@ pub(crate) unsafe fn class_accessor_source_func_ptr( let thunk = (*closure).func_ptr; if thunk != class_accessor_getter_thunk as *const u8 && thunk != class_accessor_setter_thunk as *const u8 + && thunk != class_static_accessor_getter_thunk as *const u8 + && thunk != class_static_accessor_setter_thunk as *const u8 { return None; } @@ -265,15 +307,19 @@ pub(crate) unsafe fn class_accessor_source_func_ptr( pub(crate) fn class_accessor_function_value( raw_ptr: usize, is_setter: bool, + is_static: bool, prop_name: &str, ) -> f64 { if raw_ptr == 0 { return f64::from_bits(crate::value::TAG_UNDEFINED); } - let thunk = if is_setter { - class_accessor_setter_thunk as *const u8 - } else { - class_accessor_getter_thunk as *const u8 + // The thunk matches the entry's calling convention: an instance entry + // takes the receiver as a parameter, a static one does not. + let thunk = match (is_setter, is_static) { + (true, false) => class_accessor_setter_thunk as *const u8, + (false, false) => class_accessor_getter_thunk as *const u8, + (true, true) => class_static_accessor_setter_thunk as *const u8, + (false, true) => class_static_accessor_getter_thunk as *const u8, }; let closure = crate::closure::js_closure_alloc(thunk, 1); if closure.is_null() { diff --git a/crates/perry-runtime/src/object/class_value.rs b/crates/perry-runtime/src/object/class_value.rs index b3e84eea38..5c3c9f0fac 100644 --- a/crates/perry-runtime/src/object/class_value.rs +++ b/crates/perry-runtime/src/object/class_value.rs @@ -926,9 +926,10 @@ pub(crate) const CLASS_ACCESSOR_DEFAULT_ATTRS: (bool, bool) = (false, true); /// Install — or refresh, when a half arrives later — the ClassBody static /// accessor `name` of `class_id` as an accessor property of its function /// object's own-property object: the pair holds the reflected closures and -/// the compiled static entries (`fn() -> value` / `fn(v)`, `this` armed by -/// the caller — NOT the instance `fn(this)` convention; only this module and -/// its callers read a class function object's pairs). A half whose compiled +/// the compiled static entries in the pair's STATIC fields (`fn() -> value` +/// / `fn(v)`, `this` armed by the caller — NOT the instance `fn(this)` +/// convention, so a generic reader of `raw_get`/`raw_set` never sees them). +/// A half whose compiled /// entry is unchanged keeps its closure, so reflection hands out the same /// function every time; attributes a `defineProperty` set are kept. /// Private (`#x`) accessors are not properties and are never installed. @@ -957,19 +958,22 @@ fn install_declared_static_accessor(class_id: u32, name: &str) { } else if raw == have_raw && have != 0 { have } else { - super::class_registry::class_accessor_function_value(raw, is_setter, name).to_bits() + super::class_registry::class_accessor_function_value(raw, is_setter, true, name) + .to_bits() } }; - let get = half(raw_get, have.raw_get, have.get, false); - let set = half(raw_set, have.raw_set, have.set, true); + let get = half(raw_get, have.static_get, have.get, false); + let set = half(raw_set, have.static_set, have.set, true); class_static_define_accessor( class_id, name, crate::object::accessor_pair::Accessor { get, set, - raw_get, - raw_set, + raw_get: 0, + raw_set: 0, + static_get: raw_get, + static_set: raw_set, }, enumerable, configurable, @@ -1062,10 +1066,10 @@ pub(crate) unsafe fn class_static_accessor_call_get( receiver: f64, ) -> f64 { let this = crate::object::field_get_set::accessor_receiver_override_take().unwrap_or(receiver); - if acc.raw_get != 0 { + if acc.static_get != 0 { crate::object::static_this_arm_if_unarmed(this); crate::object::static_private_owner_push(receiver); - let f: extern "C" fn() -> f64 = std::mem::transmute(acc.raw_get); + let f: extern "C" fn() -> f64 = std::mem::transmute(acc.static_get); let result = f(); crate::object::static_private_owner_pop(); crate::object::static_this_disarm(); @@ -1086,10 +1090,10 @@ pub(crate) unsafe fn class_static_accessor_call_set( receiver: f64, value: f64, ) -> bool { - if acc.raw_set != 0 { + if acc.static_set != 0 { crate::object::static_this_arm_if_unarmed(receiver); crate::object::static_private_owner_push(receiver); - let f: extern "C" fn(f64) -> f64 = std::mem::transmute(acc.raw_set); + let f: extern "C" fn(f64) -> f64 = std::mem::transmute(acc.static_set); let _ = f(value); crate::object::static_private_owner_pop(); crate::object::static_this_disarm(); diff --git a/crates/perry-runtime/src/object/descriptors/builders.rs b/crates/perry-runtime/src/object/descriptors/builders.rs index b6b73845c4..128d53fc2d 100644 --- a/crates/perry-runtime/src/object/descriptors/builders.rs +++ b/crates/perry-runtime/src/object/descriptors/builders.rs @@ -70,8 +70,18 @@ pub(crate) unsafe fn symbol_own_property_descriptor(obj_value: f64, key_value: f super::class_registry::class_own_symbol_accessor_ptrs(cid, sym_key, is_static) { return build_accessor_descriptor( - super::class_registry::class_accessor_function_value(get, false, &display_name), - super::class_registry::class_accessor_function_value(set, true, &display_name), + super::class_registry::class_accessor_function_value( + get, + false, + is_static, + &display_name, + ), + super::class_registry::class_accessor_function_value( + set, + true, + is_static, + &display_name, + ), false, true, ); diff --git a/crates/perry-runtime/src/object/inherited_read_cache_tests.rs b/crates/perry-runtime/src/object/inherited_read_cache_tests.rs index 96d6b2d1d9..cf82111428 100644 --- a/crates/perry-runtime/src/object/inherited_read_cache_tests.rs +++ b/crates/perry-runtime/src/object/inherited_read_cache_tests.rs @@ -834,7 +834,7 @@ unsafe fn install_class_getter(proto: *mut ObjectHeader, name: &str) { get: crate::value::js_nanbox_pointer(getter as i64).to_bits(), set: 0, raw_get: forty_two_raw_getter as *const () as usize, - raw_set: 0, + ..Default::default() }, crate::object::PropertyAttrs::new(true, false, true), ); diff --git a/test-files/test_gap_class_static_setter_receives_value.ts b/test-files/test_gap_class_static_setter_receives_value.ts new file mode 100644 index 0000000000..201be04b14 --- /dev/null +++ b/test-files/test_gap_class_static_setter_receives_value.ts @@ -0,0 +1,75 @@ +// A class's static setter receives the assigned VALUE on every route: a +// direct store, a store through a variable or a shared (warmed) put site, an +// inherited store, Reflect.set, and the reflected setter function called with +// an explicit receiver. A static accessor's compiled entry takes no receiver +// parameter (unlike an instance accessor's), so a route that calls it with +// the instance convention hands the setter the class instead of the value. +// +// Output must be byte-identical to node. +class HasStatic { + static seen: unknown = null; + static get tag(): unknown { + return HasStatic.seen; + } + static set tag(v: unknown) { + HasStatic.seen = "set:" + String(v); + } +} +class SubStatic extends HasStatic {} +let P: any = HasStatic; +let Q: any = SubStatic; +function put(o: any, v: unknown): void { + o.tag = v; +} +HasStatic.tag = 7; +console.log("direct", HasStatic.seen); +P.tag = 8; +console.log("var", HasStatic.seen); +put(P, 1); +console.log("fn-cold", HasStatic.seen); +Q.tag = 3; +console.log("inherited", HasStatic.seen); +put({ a: 1 }, 0); +put({ b: 1, c: 2 }, 0); +put(new Map(), 0); +put(P, 5); +console.log("fn-warm", HasStatic.seen); +put(Q, 4); +console.log("fn-warm-inherited", HasStatic.seen); +Reflect.set(P, "tag", 6); +console.log("reflect", HasStatic.seen); +const d = Object.getOwnPropertyDescriptor(HasStatic, "tag")!; +d.set!.call(HasStatic, 9); +console.log("descriptor-set", HasStatic.seen, d.get!.call(HasStatic)); + +// `this` in a static accessor is the receiver the access went through. +class ThisStatic { + static store: string = ""; + static get who(): string { + return (this as any).name; + } + static set who(v: string) { + (this as any).store = (this as any).name + "=" + v; + } +} +class ThisSub extends ThisStatic {} +ThisSub.who = "a"; +console.log("this-inherited", ThisSub.who, (ThisSub as any).store, ThisStatic.store); +const dw = Object.getOwnPropertyDescriptor(ThisStatic, "who")!; +dw.set!.call(ThisSub, "b"); +console.log("this-descriptor", dw.get!.call(ThisSub), (ThisSub as any).store); + +// A symbol-keyed static accessor, reflected. +const k = Symbol("k"); +class SymStatic { + static v: number = 0; + static get [k](): number { + return SymStatic.v; + } + static set [k](n: number) { + SymStatic.v = n * 2; + } +} +const ds = Object.getOwnPropertyDescriptor(SymStatic, k)!; +ds.set!.call(SymStatic, 11); +console.log("symbol-descriptor", SymStatic.v, ds.get!.call(SymStatic)); From e4ded6b628d133a9c30d1839dbd4ab2d8910f2e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 29 Sep 2026 15:52:27 +0200 Subject: [PATCH 10/11] changelog: name the fragments after #11667 --- ...ss-object.md => 11667-class-builtin-parent-no-class-object.md} | 0 ...ion-by-identity.md => 11667-class-registration-by-identity.md} | 0 ...vention.md => 11667-class-static-accessor-entry-convention.md} | 0 ...tatic-attrs-on-keys.md => 11667-class-static-attrs-on-keys.md} | 0 ...call-guard-cheap.md => 11667-class-static-call-guard-cheap.md} | 0 ...properties.md => 11667-class-static-methods-own-properties.md} | 0 ...c-names-fast-hash.md => 11667-class-static-names-fast-hash.md} | 0 ...insics-standalone.md => 11667-object-intrinsics-standalone.md} | 0 8 files changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{class-builtin-parent-no-class-object.md => 11667-class-builtin-parent-no-class-object.md} (100%) rename changelog.d/{class-registration-by-identity.md => 11667-class-registration-by-identity.md} (100%) rename changelog.d/{class-static-accessor-entry-convention.md => 11667-class-static-accessor-entry-convention.md} (100%) rename changelog.d/{class-static-attrs-on-keys.md => 11667-class-static-attrs-on-keys.md} (100%) rename changelog.d/{class-static-call-guard-cheap.md => 11667-class-static-call-guard-cheap.md} (100%) rename changelog.d/{class-static-methods-own-properties.md => 11667-class-static-methods-own-properties.md} (100%) rename changelog.d/{class-static-names-fast-hash.md => 11667-class-static-names-fast-hash.md} (100%) rename changelog.d/{object-intrinsics-standalone.md => 11667-object-intrinsics-standalone.md} (100%) diff --git a/changelog.d/class-builtin-parent-no-class-object.md b/changelog.d/11667-class-builtin-parent-no-class-object.md similarity index 100% rename from changelog.d/class-builtin-parent-no-class-object.md rename to changelog.d/11667-class-builtin-parent-no-class-object.md diff --git a/changelog.d/class-registration-by-identity.md b/changelog.d/11667-class-registration-by-identity.md similarity index 100% rename from changelog.d/class-registration-by-identity.md rename to changelog.d/11667-class-registration-by-identity.md diff --git a/changelog.d/class-static-accessor-entry-convention.md b/changelog.d/11667-class-static-accessor-entry-convention.md similarity index 100% rename from changelog.d/class-static-accessor-entry-convention.md rename to changelog.d/11667-class-static-accessor-entry-convention.md diff --git a/changelog.d/class-static-attrs-on-keys.md b/changelog.d/11667-class-static-attrs-on-keys.md similarity index 100% rename from changelog.d/class-static-attrs-on-keys.md rename to changelog.d/11667-class-static-attrs-on-keys.md diff --git a/changelog.d/class-static-call-guard-cheap.md b/changelog.d/11667-class-static-call-guard-cheap.md similarity index 100% rename from changelog.d/class-static-call-guard-cheap.md rename to changelog.d/11667-class-static-call-guard-cheap.md diff --git a/changelog.d/class-static-methods-own-properties.md b/changelog.d/11667-class-static-methods-own-properties.md similarity index 100% rename from changelog.d/class-static-methods-own-properties.md rename to changelog.d/11667-class-static-methods-own-properties.md diff --git a/changelog.d/class-static-names-fast-hash.md b/changelog.d/11667-class-static-names-fast-hash.md similarity index 100% rename from changelog.d/class-static-names-fast-hash.md rename to changelog.d/11667-class-static-names-fast-hash.md diff --git a/changelog.d/object-intrinsics-standalone.md b/changelog.d/11667-object-intrinsics-standalone.md similarity index 100% rename from changelog.d/object-intrinsics-standalone.md rename to changelog.d/11667-object-intrinsics-standalone.md From 67d63ec0aefe5221730deb6549a88e58e43811ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 29 Sep 2026 15:21:49 +0000 Subject: [PATCH 11/11] test: #336 namespace classes keep their own identity (expected output b/2) --- test-files/test_issue_336_class_keys_collision.ts | 8 ++------ .../expected/test_issue_336_class_keys_collision.txt | 6 +++--- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/test-files/test_issue_336_class_keys_collision.ts b/test-files/test_issue_336_class_keys_collision.ts index fbfae30777..34661aa6b8 100644 --- a/test-files/test_issue_336_class_keys_collision.ts +++ b/test-files/test_issue_336_class_keys_collision.ts @@ -41,12 +41,8 @@ namespace B { // (deduplicated) class. Pre-fix this test failed at clang IR validation; // post-fix it compiles + runs. // -// The lookup pipeline is purely name-based today, so the dedup keeps the -// FIRST `Refinement` (the one in namespace A) and both `make()` bodies -// resolve `new Refinement()` to it — `b.kind` therefore prints `a`, not -// `b`. That's the existing function-scoped semantics extended to namespace -// scope; pinning a distinct identity per scope would need scope info on -// `Expr::New { class_name }` and is a separate, larger change. +// Each namespace resolves its own Refinement (classes are +// registered by identity), so b.kind prints b. const a = A.make(); const b = B.make(); console.log("a.kind:", a.kind); diff --git a/test-parity/expected/test_issue_336_class_keys_collision.txt b/test-parity/expected/test_issue_336_class_keys_collision.txt index 20e5e041ef..cf0df79b10 100644 --- a/test-parity/expected/test_issue_336_class_keys_collision.txt +++ b/test-parity/expected/test_issue_336_class_keys_collision.txt @@ -1,6 +1,6 @@ a.kind: a a.value: 1 a.describe(): a/1 -b.kind: a -b.value: 1 -b.describe(): a/1 +b.kind: b +b.value: 2 +b.describe(): b/2