From 25bd57f7a50fbb3a18fa298e155216247005b2b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 24 Sep 2026 02:23:33 +0200 Subject: [PATCH 1/5] fix(runtime): trace Object.create prototypes through their owners --- .../pending-object-create-prototype.md | 3 + .../perry-runtime/src/gc/tests/cycle_state.rs | 4 +- crates/perry-runtime/src/gc/tests/mod.rs | 1 + .../src/gc/tests/object_create.rs | 112 +++++++++++++++ .../src/object/class_registry.rs | 6 +- .../class_registry/prototype_objects.rs | 2 +- .../src/object/inherited_read_cache_tests.rs | 16 +-- .../src/object/object_ops/prototype.rs | 136 ++++-------------- .../src/object/prototype_chain.rs | 18 ++- scripts/addr_class_ratchet_baseline.txt | 2 - 10 files changed, 171 insertions(+), 129 deletions(-) create mode 100644 changelog.d/pending-object-create-prototype.md create mode 100644 crates/perry-runtime/src/gc/tests/object_create.rs diff --git a/changelog.d/pending-object-create-prototype.md b/changelog.d/pending-object-create-prototype.md new file mode 100644 index 0000000000..a78df8071f --- /dev/null +++ b/changelog.d/pending-object-create-prototype.md @@ -0,0 +1,3 @@ +Fix `Object.create(proto)` permanently retaining prototypes and consuming a synthetic class ID on every call. Created objects now carry the existing GC-traced per-object prototype link, so class-ID exhaustion cannot silently remove their prototype. Fresh links preserve individual-chain dispatch without invalidating class lookup, property-plan, or array element-shape caches. Root both endpoints across prototype metadata allocation. + +Regression coverage checks repeated creation, prototype identity and live inheritance, null prototypes, absence of permanent class roots, unchanged cache epochs, and a copying collection with only the descendant rooted. diff --git a/crates/perry-runtime/src/gc/tests/cycle_state.rs b/crates/perry-runtime/src/gc/tests/cycle_state.rs index 4fa2daa7cf..78048462fe 100644 --- a/crates/perry-runtime/src/gc/tests/cycle_state.rs +++ b/crates/perry-runtime/src/gc/tests/cycle_state.rs @@ -1634,7 +1634,9 @@ fn full_cycle_prototype_object_store_after_root_scan_preserves_new_value() { "full cycle should keep root barriers active after root scan" ); - let _created = crate::object::js_object_create(f64::from_bits(ptr_bits(child as usize))); + // Exercise the permanent class registry directly: Object.create now + // records an owner-traced edge and must not populate this root table. + crate::object::class_prototype_object_root_store(0x5104, child.cast()); run_cycle_in_single_unit_steps(&mut state); assert!( diff --git a/crates/perry-runtime/src/gc/tests/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index 1a0a16e461..11cc2116e8 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -61,6 +61,7 @@ mod lazy_tape_side_alloc; mod leaf_marks; mod map_store; mod mark_slot_hoists; +mod object_create; mod oldgen; mod os_tag; mod promote_in_place; diff --git a/crates/perry-runtime/src/gc/tests/object_create.rs b/crates/perry-runtime/src/gc/tests/object_create.rs new file mode 100644 index 0000000000..d54c43052d --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/object_create.rs @@ -0,0 +1,112 @@ +//! Object.create must keep its prototype alive only through its owner. +use super::super::*; +use super::support::*; +use crate::object::*; + +#[test] +fn object_create_does_not_register_classes_or_invalidate_existing_caches() { + let _guard = CopyingNurseryTestGuard::new(1); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let proto = js_object_alloc(0, 0); + let bits = ptr_bits(proto as usize); + let next_id = NEXT_SYNTHETIC_CLASS_ID.load(std::sync::atomic::Ordering::Relaxed); + let class_generation = class_lookup_surface_generation(); + let plan_epoch = prop_plan::prop_plan_semantic_epoch(); + let element_epoch = crate::array::js_array_element_shape_epoch(); + for _ in 0..1024 { + let obj = js_object_create(f64::from_bits(bits)); + assert_eq!(js_object_get_prototype_of(obj).to_bits(), bits); + let ptr = crate::value::js_nanbox_get_pointer(obj) as *const ObjectHeader; + assert_eq!(unsafe { (*ptr).class_id }, 0); + } + assert_eq!( + NEXT_SYNTHETIC_CLASS_ID.load(std::sync::atomic::Ordering::Relaxed), + next_id + ); + assert_eq!(class_lookup_surface_generation(), class_generation); + assert_eq!(prop_plan::prop_plan_semantic_epoch(), plan_epoch); + assert_eq!(crate::array::js_array_element_shape_epoch(), element_epoch); + let mut retained = false; + scan_class_side_table_roots(&mut |value| { + retained |= value.to_bits() == bits; + }); + assert!( + !retained, + "the prototype must not become a permanent class root" + ); +} + +#[test] +fn object_create_prototype_edge_survives_copying_without_a_separate_root() { + let _guard = CopyingNurseryTestGuard::new(1); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let proto = js_object_alloc(0, 1); + js_object_set_field(proto, 0, crate::value::JSValue::number(42.0)); + let obj = js_object_create(f64::from_bits(ptr_bits(proto as usize))); + js_shadow_slot_set(0, obj.to_bits()); + let trace = collect_minor_trace(GcTriggerKind::Direct); + assert_copied_minor_trace(&trace, true, CopiedMinorFallbackReason::None, false); + let moved_obj = f64::from_bits(js_shadow_slot_get(0)); + assert_ne!(moved_obj.to_bits(), obj.to_bits(), "owner must move"); + let moved_proto = js_object_get_prototype_of(moved_obj); + assert_ne!( + moved_proto.to_bits(), + ptr_bits(proto as usize), + "prototype must move" + ); + let ptr = crate::value::js_nanbox_get_pointer(moved_proto) as *const ObjectHeader; + assert_eq!(js_object_get_field(ptr, 0).to_number(), 42.0); +} + +#[test] +fn object_create_inherits_live_properties_and_preserves_null() { + let _guard = CopyingNurseryTestGuard::new(1); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let key = crate::string::js_string_from_bytes(b"value".as_ptr(), 5); + let proto = js_object_alloc(0, 0); + let obj = js_object_create(f64::from_bits(ptr_bits(proto as usize))); + let child = js_object_create(obj); + let ptr = crate::value::js_nanbox_get_pointer(child) as *const ObjectHeader; + for value in [42.0, 73.0] { + js_object_set_field_by_name(proto, key, value); + assert_eq!(js_object_get_field_by_name(ptr, key).to_number(), value); + assert_eq!( + js_object_has_own(child, crate::value::js_nanbox_string(key as i64)).to_bits(), + crate::value::TAG_FALSE + ); + } + let null_obj = js_object_create(f64::from_bits(crate::value::TAG_NULL)); + assert_eq!( + js_object_get_prototype_of(null_obj).to_bits(), + crate::value::TAG_NULL + ); +} + +#[test] +fn object_create_keeps_distinct_prototypes_and_instanceof_chains() { + let _guard = CopyingNurseryTestGuard::new(1); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + const CLASS: u32 = 0x5106; + unsafe { + js_register_class_id(CLASS); + js_register_class_name(CLASS, b"CreatedBase".as_ptr(), 11); + } + let proto = js_object_alloc(0, 0); + class_decl_prototype_object_root_store(CLASS, proto); + let obj = js_object_create(f64::from_bits(ptr_bits(proto as usize))); + let child = js_object_create(obj); + let other = js_object_create(f64::from_bits(ptr_bits(js_object_alloc(0, 0) as usize))); + assert_eq!(js_instanceof(obj, CLASS).to_bits(), crate::value::TAG_TRUE); + assert_eq!( + js_instanceof(child, CLASS).to_bits(), + crate::value::TAG_TRUE + ); + assert_eq!( + js_instanceof(other, CLASS).to_bits(), + crate::value::TAG_FALSE + ); + assert_ne!( + js_object_get_prototype_of(obj).to_bits(), + js_object_get_prototype_of(other).to_bits() + ); +} diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index 7a09d9a7c4..a5aec30f00 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -106,9 +106,9 @@ pub use state::{ // ── prototype_objects.rs ──────────────────────────────────────────────────── pub(crate) use prototype_objects::{ - alloc_synthetic_class_id, class_prototype_object, ensure_function_prototype_object, - function_class_id, function_value_for_class_id, proto_chain_symbol_slot, - resolve_proto_chain_field, resolve_proto_chain_field_with_receiver, resolve_proto_chain_symbol, + class_prototype_object, ensure_function_prototype_object, function_class_id, + function_value_for_class_id, proto_chain_symbol_slot, resolve_proto_chain_field, + resolve_proto_chain_field_with_receiver, resolve_proto_chain_symbol, synthetic_class_prototype_object, SYNTHETIC_CLASS_ID_BASE, }; pub use prototype_objects::{ diff --git a/crates/perry-runtime/src/object/class_registry/prototype_objects.rs b/crates/perry-runtime/src/object/class_registry/prototype_objects.rs index b95ab11076..9a8f4e168e 100644 --- a/crates/perry-runtime/src/object/class_registry/prototype_objects.rs +++ b/crates/perry-runtime/src/object/class_registry/prototype_objects.rs @@ -194,7 +194,7 @@ per_test_global! { /// collides with the reserved builtin ids (`0xFFFF_0000..`), and on u32 wrap /// it lands back in — among others — the ShapeId range. Exhaustion is /// unreachable in practice (2^30 ids, one per distinct -/// `Object.create(proto)` / `F.prototype = X` FUNCTION, not per call), so +/// function constructor / `F.prototype = X` function, not per instance), so /// saturating is the conservative answer: `0` means "no synthetic id", which /// every caller already handles as "stays parentless". pub(crate) fn alloc_synthetic_class_id() -> u32 { 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 f287283bd3..968f81317f 100644 --- a/crates/perry-runtime/src/object/inherited_read_cache_tests.rs +++ b/crates/perry-runtime/src/object/inherited_read_cache_tests.rs @@ -383,10 +383,8 @@ fn several_object_create_receivers_do_not_evict_each_other() { unsafe { let proto = crate::object::js_object_alloc(0, 4); set(proto, "irc_shared", 13.0); - // Eight receivers built the same way. `js_object_create` mints a FRESH - // synthetic class id per call, so these have eight DIFFERENT class ids - // and one identical shape — and the slot index hashed only - // (shape, key), so all eight landed in one direct-mapped slot. + // Fresh objects sharing a prototype also share a class id and shape. + // Their inherited lookup should reuse a single cache entry. let mut objs = Vec::new(); for i in 0..8 { let created = crate::object::js_object_create(boxed(proto)); @@ -411,15 +409,7 @@ fn several_object_create_receivers_do_not_evict_each_other() { let neg = inherited_read_cache_neg_served(); let reads = (objs.len() * rounds) as u64; - assert_eq!( - primes, - objs.len() as u64, - "primed {primes} times for {} receivers. Exactly one prime per \ - receiver is the property: more means the entries are evicting \ - each other and every read pays a full chain walk AND an entry \ - write", - objs.len() - ); + assert_eq!(primes, 1, "equivalent receivers should share one cache entry"); // At most ONE decline, and it is expected rather than tolerated. // diff --git a/crates/perry-runtime/src/object/object_ops/prototype.rs b/crates/perry-runtime/src/object/object_ops/prototype.rs index 202a50191f..6763ccb6ad 100644 --- a/crates/perry-runtime/src/object/object_ops/prototype.rs +++ b/crates/perry-runtime/src/object/object_ops/prototype.rs @@ -56,118 +56,38 @@ pub extern "C" fn js_get_global_this_builtin_value(name_ptr: *const u8, name_len f64::from_bits(bits) } -/// Object.create(proto) — create empty object. Perry ignores prototype; Object.create(null) returns {}. +/// Object.create(proto) — create an empty object with an owner-traced prototype. #[no_mangle] pub extern "C" fn js_object_create(proto_value: f64) -> f64 { - // #809: actually wire up the prototype. Pre-fix this ignored its - // argument entirely, so `Object.create(Proto)` returned a bare empty - // object — `inst.method()` / `inst.prop` saw nothing and threw - // `TypeError: is not a function`. Reuse the #711 prototype-object - // machinery: allocate a synthetic class_id, map it to `proto` in - // CLASS_PROTOTYPE_OBJECTS, and stamp the new object with that id. The - // chain walk in `js_object_get_field_by_name` (the `class_id != 0` - // branch) then resolves missing own props/methods off `proto`. - // - // `Object.create(null)` (or a non-object proto / a builtin-backed - // Set/Map/Regex source Perry can't model as a prototype) falls back - // to the original behavior: a plain prototype-less object. - const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; - - // `Object.create(proxy)` — a Proxy is a small registered id, not a real - // heap pointer, so the synthetic-class-id modeling below (which stores a - // REAL prototype pointer) can't represent it, and the `is_valid_obj_ptr` - // check would reject it outright (falling back to a plain, prototype-less - // object — wrong: reads/writes/`in` on the result must still route through - // the proxy). Record it in the SAME observable `[[Prototype]]` side table - // `Object.setPrototypeOf` uses instead: a plain (class_id 0, non-null-proto) - // object whose prototype hop the generic chain walks (`ordinary_has_property`, - // `own_set_descriptor`'s `prototype_of_for_set`, field-get) already resolve - // through the proxy's traps. (test262 has/call-in-prototype.js, - // has/call-object-create.js, set/call-parameters-prototype.js.) - if crate::proxy::js_proxy_is_proxy(proto_value) != 0 { - let obj = js_object_alloc(0, 0); - crate::object::prototype_chain::object_set_user_prototype( - obj as usize, - proto_value.to_bits(), - ); - return f64::from_bits((obj as u64) | POINTER_TAG); - } - - // Integer-indexed exotic objects are valid prototypes even though their - // TypedArrayHeader cannot be modeled as an ObjectHeader-backed synthetic - // class prototype. Preserve the exact object identity in the ordinary - // per-instance prototype side table so its [[Set]] intercepts canonical - // numeric keys on descendants. - if crate::typedarray_props::typed_array_addr_from_value(proto_value).is_some() { - let obj = js_object_alloc(0, 0); - crate::object::prototype_chain::object_set_user_prototype( - obj as usize, - proto_value.to_bits(), - ); - return f64::from_bits((obj as u64) | POINTER_TAG); - } - - let mut class_id: u32 = 0; - let proto_bits = proto_value.to_bits(); - if (proto_bits & 0xFFFF_0000_0000_0000) == POINTER_TAG { - let proto_ptr = crate::value::js_nanbox_get_pointer(proto_value) as *mut ObjectHeader; - if !proto_ptr.is_null() && (proto_ptr as usize) > 0x10000 { - let proto_addr = proto_ptr as usize; - let modellable = !(crate::set::is_registered_set(proto_addr) - || crate::map::is_registered_map(proto_addr) - || crate::regex::is_regex_pointer(proto_ptr as *const u8)); - let valid = modellable && is_valid_obj_ptr(proto_ptr as *const u8); - if valid { - let cid = crate::object::class_registry::alloc_synthetic_class_id(); - class_prototype_object_root_store(cid, proto_ptr); - unsafe { js_register_class_id(cid) }; - // #1805: link the synthetic class_id into the original class's - // inheritance chain. `Object.getPrototypeOf(instance)` returns - // the instance pointer itself in Perry's model (see - // `js_object_get_prototype_of`), so `proto_ptr` here is a real - // class instance whose `class_id` field IS the user class's - // id. Registering it as the synthetic cid's parent lets - // `js_instanceof`'s `get_parent_class_id` walk reach the - // original class and match — without this, the chain stopped - // at the unregistered synthetic id and `Object.create(proto) - // instanceof C` was always false even though property / - // getter dispatch through the chain worked correctly. - let parent_class_id = unsafe { (*proto_ptr).class_id }; - // #8343 followup: `NATIVE_MODULE_CLASS_ID` (0xFFFFFFFE) is a - // sentinel tagging native-module namespace objects, NOT a real - // declared class. Registering it as a synthetic class's parent - // makes `get_parent_class_id` return it, and - // `js_object_get_prototype_of`'s class-ref branch then returns - // the raw sentinel as an INT32-tagged class ref (`-2`). - // `Object.create(that)` rejects it with - // `TypeError: Object prototype may only be an Object or null: -2` - // (rolldown's `__toESM` → `Object.create(Object.getPrototypeOf(mod))` - // chain). Skip the registration so the synthetic class is treated - // as a root — its prototype is already stored in - // `CLASS_PROTOTYPE_OBJECTS` by `class_prototype_object_root_store` - // above, which is what `getPrototypeOf` reads. - if parent_class_id != 0 - && parent_class_id != cid - && parent_class_id != super::super::native_module::NATIVE_MODULE_CLASS_ID - { - register_class(cid, parent_class_id); - } - class_id = cid; + // Keep the existing accepted prototype kinds, but store their identity on + // the instance instead of permanently rooting them under a new class id. + let valid = crate::proxy::js_proxy_is_proxy(proto_value) != 0 + || crate::typedarray_props::typed_array_addr_from_value(proto_value).is_some() + || { + let value = crate::value::JSValue::from_bits(proto_value.to_bits()); + if value.is_pointer() { + let ptr = value.as_pointer::(); + let addr = ptr as usize; + crate::value::addr_class::is_above_handle_band(addr) + && !crate::set::is_registered_set(addr) + && !crate::map::is_registered_map(addr) + && !crate::regex::is_regex_pointer(ptr as *const u8) + && is_valid_obj_ptr(ptr as *const u8) + } else { + false } - } + }; + if !valid { + return crate::value::js_nanbox_pointer(js_object_alloc_null_proto(0, 0) as i64); } - // #1175: when `proto_value` is null/undefined/non-object, the resulting - // object has no [[Prototype]]. Stamp OBJ_FLAG_NULL_PROTO so - // `Object.getPrototypeOf(Object.create(null))` returns null (it - // previously returned the object itself). - let null_proto = class_id == 0; - let obj = if null_proto { - js_object_alloc_null_proto(class_id, 0) - } else { - js_object_alloc(class_id, 0) - }; - // Return NaN-boxed pointer - f64::from_bits((obj as u64) | 0x7FFD_0000_0000_0000) + let scope = crate::gc::RuntimeHandleScope::new(); + let proto = scope.root_nanbox_f64(proto_value); + let obj = scope.root_raw_mut_ptr(js_object_alloc(0, 0)); + crate::object::prototype_chain::object_link_created_prototype( + obj.get_raw_mut_ptr::() as usize, + proto.get_nanbox_u64(), + ); + crate::value::js_nanbox_pointer(obj.get_raw_mut_ptr::() as i64) } /// Object.getPrototypeOf(obj): diff --git a/crates/perry-runtime/src/object/prototype_chain.rs b/crates/perry-runtime/src/object/prototype_chain.rs index f08f2fac23..41f1d23fb0 100644 --- a/crates/perry-runtime/src/object/prototype_chain.rs +++ b/crates/perry-runtime/src/object/prototype_chain.rs @@ -396,6 +396,7 @@ enum PrototypeLinkKind { ClassEvaluation, RuntimeWiring, UserOverride, + FreshObject, } /// Record runtime prototype wiring while preserving the loud setter's cache @@ -433,9 +434,19 @@ pub(crate) fn object_link_class_evaluation_prototype(obj_ptr: usize, proto_bits: object_set_static_prototype_impl(obj_ptr, proto_bits, PrototypeLinkKind::ClassEvaluation) } +/// Install Object.create's individual chain before the object escapes. It +/// needs the user-override dispatch guards, but cannot invalidate any existing +/// receiver's store plans or element-shape proofs. +pub(crate) fn object_link_created_prototype(obj_ptr: usize, proto_bits: u64) { + object_set_static_prototype_impl(obj_ptr, proto_bits, PrototypeLinkKind::FreshObject) +} + fn object_set_static_prototype_impl(obj_ptr: usize, proto_bits: u64, link_kind: PrototypeLinkKind) { let prototype_diverged = link_kind != PrototypeLinkKind::ClassDefault; - let user_override = link_kind == PrototypeLinkKind::UserOverride; + let user_override = matches!( + link_kind, + PrototypeLinkKind::UserOverride | PrototypeLinkKind::FreshObject + ); if obj_ptr == 0 { return; } @@ -450,6 +461,9 @@ fn object_set_static_prototype_impl(obj_ptr: usize, proto_bits: u64, link_kind: // from the meta pointer AFTER the mark's allocation, and it is carried as a // plain u64 to the divergence below rather than re-read through a pointer // that allocation may have moved. + let scope = crate::gc::RuntimeHandleScope::new(); + let owner_handle = scope.root_raw_mut_ptr(obj_ptr as *mut u8); + let prototype_handle = scope.root_heap_word_u64(proto_bits); let prototype_serial: Option = unsafe { let prototype = crate::value::JSValue::from_bits(proto_bits); if prototype.is_pointer() { @@ -462,6 +476,8 @@ fn object_set_static_prototype_impl(obj_ptr: usize, proto_bits: u64, link_kind: None } }; + let obj_ptr = owner_handle.get_raw_mut_ptr::() as usize; + let proto_bits = prototype_handle.get_heap_word_u64(); if !ARRAY_TARGET_PROTO_RECORDED.load(Ordering::Relaxed) && obj_ptr >= crate::gc::GC_HEADER_SIZE + 0x1000 && crate::value::addr_class::is_above_handle_band(obj_ptr) diff --git a/scripts/addr_class_ratchet_baseline.txt b/scripts/addr_class_ratchet_baseline.txt index 4416e30c8c..70f3f87caf 100644 --- a/scripts/addr_class_ratchet_baseline.txt +++ b/scripts/addr_class_ratchet_baseline.txt @@ -139,7 +139,6 @@ handle-floor | crates/perry-runtime/src/object/object_ops/define_property.rs | 3 handle-floor | crates/perry-runtime/src/object/object_ops/descriptor_helpers.rs | 8 handle-floor | crates/perry-runtime/src/object/object_ops/has_own.rs | 4 handle-floor | crates/perry-runtime/src/object/object_ops/keys_array.rs | 6 -handle-floor | crates/perry-runtime/src/object/object_ops/prototype.rs | 1 handle-floor | crates/perry-runtime/src/object/object_ops_frozen.rs | 4 handle-floor | crates/perry-runtime/src/object/polymorphic_index.rs | 2 handle-floor | crates/perry-runtime/src/object/property_key.rs | 1 @@ -245,7 +244,6 @@ lone-valid-obj-ptr | crates/perry-runtime/src/object/object_ops/descriptor_helpe lone-valid-obj-ptr | crates/perry-runtime/src/object/object_ops/from_entries.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/object/object_ops/has_own.rs | 2 lone-valid-obj-ptr | crates/perry-runtime/src/object/object_ops/keys_array.rs | 1 -lone-valid-obj-ptr | crates/perry-runtime/src/object/object_ops/prototype.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/object/polymorphic_index.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/object/prototype_chain.rs | 2 lone-valid-obj-ptr | crates/perry-runtime/src/object/util_types.rs | 4 From 350447b7153b2ef68514fe1d22fb9059c1825abd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 24 Sep 2026 02:24:05 +0200 Subject: [PATCH 2/5] docs: key Object.create changelog to PR 11166 --- ...bject-create-prototype.md => 11166-object-create-prototype.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{pending-object-create-prototype.md => 11166-object-create-prototype.md} (100%) diff --git a/changelog.d/pending-object-create-prototype.md b/changelog.d/11166-object-create-prototype.md similarity index 100% rename from changelog.d/pending-object-create-prototype.md rename to changelog.d/11166-object-create-prototype.md From 0509578723633e869f5657b15341712abe5a1856 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 24 Sep 2026 03:18:59 +0200 Subject: [PATCH 3/5] =?UTF-8?q?fix(runtime):=20Object.create=20review=20fi?= =?UTF-8?q?xes=20=E2=80=94=20handle-scoped=20link,=20barrier=20and=20recla?= =?UTF-8?q?mation=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - js_object_create passes the rooted owner through with_mut_ptr and re-reads it after the self-rooting link; the static-prototype link reloads the owner via across_mut (raw-handle debt back to the 897 baseline) - restore an Object.create-driven incremental-cycle barrier test - add a test that an unreachable Object.create prototype is collected - rustfmt inherited_read_cache_tests.rs --- .../perry-runtime/src/gc/tests/cycle_state.rs | 36 +++++++++++++++++-- .../src/gc/tests/object_create.rs | 28 +++++++++++++++ .../src/object/inherited_read_cache_tests.rs | 5 ++- .../src/object/object_ops/prototype.rs | 15 +++++--- .../src/object/prototype_chain.rs | 27 +++++++------- 5 files changed, 90 insertions(+), 21 deletions(-) diff --git a/crates/perry-runtime/src/gc/tests/cycle_state.rs b/crates/perry-runtime/src/gc/tests/cycle_state.rs index 78048462fe..e79b438770 100644 --- a/crates/perry-runtime/src/gc/tests/cycle_state.rs +++ b/crates/perry-runtime/src/gc/tests/cycle_state.rs @@ -97,7 +97,7 @@ fn alloc_tracked_test_closure() -> *mut u8 { child } -fn alloc_tracked_test_object() -> *mut crate::object::ObjectHeader { +pub(super) fn alloc_tracked_test_object() -> *mut crate::object::ObjectHeader { let header_size = std::mem::size_of::(); let fields_size = 8 * std::mem::size_of::(); let child = @@ -1635,7 +1635,8 @@ fn full_cycle_prototype_object_store_after_root_scan_preserves_new_value() { ); // Exercise the permanent class registry directly: Object.create now - // records an owner-traced edge and must not populate this root table. + // records an owner-traced edge and must not populate this root table + // (its barrier coverage is the Object.create test below). crate::object::class_prototype_object_root_store(0x5104, child.cast()); run_cycle_in_single_unit_steps(&mut state); @@ -1645,6 +1646,37 @@ fn full_cycle_prototype_object_store_after_root_scan_preserves_new_value() { ); } +#[test] +fn full_cycle_object_create_after_root_scan_preserves_prototype_via_owner() { + let _guard = CopyingNurseryTestGuard::new(1); + let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + + let child = alloc_tracked_test_object(); + let mut state = GcCycleState::new_full(trace_snapshot(GcTriggerKind::Manual)); + run_cycle_until_phase(&mut state, GcCyclePhase::BlockPersistence); + assert!( + incremental_mark_barrier_active(), + "full cycle should keep root barriers active after root scan" + ); + + // The owner is created after the root scan and the prototype is held + // ONLY by its meta record: the meta-slot store's barrier must shade it. + let created = crate::object::js_object_create(f64::from_bits(ptr_bits(child as usize))); + js_shadow_slot_set(0, created.to_bits()); + run_cycle_in_single_unit_steps(&mut state); + + assert!( + malloc_user_ptr_tracked(child as *mut u8), + "an Object.create prototype stored after root scan must survive via its owner's barrier" + ); + let created = f64::from_bits(js_shadow_slot_get(0)); + assert_eq!( + crate::object::js_object_get_prototype_of(created).to_bits(), + ptr_bits(child as usize) + ); + js_shadow_slot_set(0, crate::value::TAG_UNDEFINED); +} + #[test] fn full_cycle_parent_closure_store_after_root_scan_preserves_new_value() { let _guard = CopyingNurseryTestGuard::new(0); diff --git a/crates/perry-runtime/src/gc/tests/object_create.rs b/crates/perry-runtime/src/gc/tests/object_create.rs index d54c43052d..12dd3533fb 100644 --- a/crates/perry-runtime/src/gc/tests/object_create.rs +++ b/crates/perry-runtime/src/gc/tests/object_create.rs @@ -110,3 +110,31 @@ fn object_create_keeps_distinct_prototypes_and_instanceof_chains() { js_object_get_prototype_of(other).to_bits() ); } + +#[test] +fn object_create_prototype_is_reclaimed_once_its_owner_dies() { + let _guard = CopyingNurseryTestGuard::new(1); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let proto = super::cycle_state::alloc_tracked_test_object(); + let obj = js_object_create(f64::from_bits(ptr_bits(proto as usize))); + // Positive control: while the owner is rooted, its prototype survives a + // full collection through the owner's meta record alone. + js_shadow_slot_set(0, obj.to_bits()); + super::dead_owner_side_tables::full_gc_with_no_block_persistence(); + assert!( + malloc_user_ptr_tracked(proto as *mut u8), + "a live owner must keep its prototype" + ); + let obj = f64::from_bits(js_shadow_slot_get(0)); + assert_eq!( + js_object_get_prototype_of(obj).to_bits(), + ptr_bits(proto as usize) + ); + // Once the owner is unreachable nothing else may hold the prototype. + js_shadow_slot_set(0, crate::value::TAG_UNDEFINED); + super::dead_owner_side_tables::full_gc_with_no_block_persistence(); + assert!( + !malloc_user_ptr_tracked(proto as *mut u8), + "an unreachable Object.create prototype must be collected" + ); +} 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 968f81317f..4dac45db6e 100644 --- a/crates/perry-runtime/src/object/inherited_read_cache_tests.rs +++ b/crates/perry-runtime/src/object/inherited_read_cache_tests.rs @@ -409,7 +409,10 @@ fn several_object_create_receivers_do_not_evict_each_other() { let neg = inherited_read_cache_neg_served(); let reads = (objs.len() * rounds) as u64; - assert_eq!(primes, 1, "equivalent receivers should share one cache entry"); + assert_eq!( + primes, 1, + "equivalent receivers should share one cache entry" + ); // At most ONE decline, and it is expected rather than tolerated. // diff --git a/crates/perry-runtime/src/object/object_ops/prototype.rs b/crates/perry-runtime/src/object/object_ops/prototype.rs index 6763ccb6ad..7107dd4fd6 100644 --- a/crates/perry-runtime/src/object/object_ops/prototype.rs +++ b/crates/perry-runtime/src/object/object_ops/prototype.rs @@ -83,11 +83,16 @@ pub extern "C" fn js_object_create(proto_value: f64) -> f64 { let scope = crate::gc::RuntimeHandleScope::new(); let proto = scope.root_nanbox_f64(proto_value); let obj = scope.root_raw_mut_ptr(js_object_alloc(0, 0)); - crate::object::prototype_chain::object_link_created_prototype( - obj.get_raw_mut_ptr::() as usize, - proto.get_nanbox_u64(), - ); - crate::value::js_nanbox_pointer(obj.get_raw_mut_ptr::() as i64) + // The link is a self-rooting entry point: it roots the owner and the + // prototype before its meta-record allocation, so the handle is re-read + // afterwards for the post-collection address. + obj.with_mut_ptr::(|owner| { + crate::object::prototype_chain::object_link_created_prototype( + owner as usize, + proto.get_nanbox_u64(), + ) + }); + obj.with_mut_ptr::(|owner| crate::value::js_nanbox_pointer(owner as i64)) } /// Object.getPrototypeOf(obj): diff --git a/crates/perry-runtime/src/object/prototype_chain.rs b/crates/perry-runtime/src/object/prototype_chain.rs index 41f1d23fb0..dda094cca6 100644 --- a/crates/perry-runtime/src/object/prototype_chain.rs +++ b/crates/perry-runtime/src/object/prototype_chain.rs @@ -464,19 +464,20 @@ fn object_set_static_prototype_impl(obj_ptr: usize, proto_bits: u64, link_kind: let scope = crate::gc::RuntimeHandleScope::new(); let owner_handle = scope.root_raw_mut_ptr(obj_ptr as *mut u8); let prototype_handle = scope.root_heap_word_u64(proto_bits); - let prototype_serial: Option = unsafe { - let prototype = crate::value::JSValue::from_bits(proto_bits); - if prototype.is_pointer() { - crate::object::proto_validity::mark_object_as_prototype( - prototype.as_pointer::() as usize, - ) - } else if proto_bits == crate::value::TAG_NULL { - Some(crate::object::proto_validity::NULL_PROTOTYPE_SERIAL) - } else { - None - } - }; - let obj_ptr = owner_handle.get_raw_mut_ptr::() as usize; + let (prototype_serial, obj_ptr): (Option, *mut u8) = + owner_handle.across_mut::(|| unsafe { + let prototype = crate::value::JSValue::from_bits(proto_bits); + if prototype.is_pointer() { + crate::object::proto_validity::mark_object_as_prototype( + prototype.as_pointer::() as usize, + ) + } else if proto_bits == crate::value::TAG_NULL { + Some(crate::object::proto_validity::NULL_PROTOTYPE_SERIAL) + } else { + None + } + }); + let obj_ptr = obj_ptr as usize; let proto_bits = prototype_handle.get_heap_word_u64(); if !ARRAY_TARGET_PROTO_RECORDED.load(Ordering::Relaxed) && obj_ptr >= crate::gc::GC_HEADER_SIZE + 0x1000 From 74cd1afce926515a733b8d17b9e33a9f4ac0bbec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 24 Sep 2026 03:21:42 +0200 Subject: [PATCH 4/5] docs: update 11166 changelog fragment --- changelog.d/11166-object-create-prototype.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/changelog.d/11166-object-create-prototype.md b/changelog.d/11166-object-create-prototype.md index a78df8071f..96a0a7c8bf 100644 --- a/changelog.d/11166-object-create-prototype.md +++ b/changelog.d/11166-object-create-prototype.md @@ -1,3 +1,5 @@ -Fix `Object.create(proto)` permanently retaining prototypes and consuming a synthetic class ID on every call. Created objects now carry the existing GC-traced per-object prototype link, so class-ID exhaustion cannot silently remove their prototype. Fresh links preserve individual-chain dispatch without invalidating class lookup, property-plan, or array element-shape caches. Root both endpoints across prototype metadata allocation. +Fix `Object.create(proto)` permanently retaining prototypes and consuming a synthetic class ID on every call. Created objects now carry the existing GC-traced per-object prototype link (class id 0), so class-ID exhaustion cannot silently remove their prototype and an unreachable prototype is collected with its last owner. Fresh links preserve individual-chain dispatch without invalidating class lookup, property-plan, or array element-shape caches. Root both endpoints across prototype metadata allocation. -Regression coverage checks repeated creation, prototype identity and live inheritance, null prototypes, absence of permanent class roots, unchanged cache epochs, and a copying collection with only the descendant rooted. +Cost note: every `Object.create` with an object prototype now latches `USER_PROTO_OVERRIDE_EVER`, so after the first one each `instanceof` miss pays the user-override registry probes, and each created object gets a meta record plus a shape transition. That replaces a per-call class-registry write lock and global cache invalidation. + +Regression coverage checks repeated creation, prototype identity and live inheritance, null prototypes, absence of permanent class roots, unchanged cache epochs, a copying collection with only the descendant rooted, the meta-slot barrier during an incremental full cycle, and reclamation of an unreachable prototype. From 94d4f8ef6bf0d18509ff1a7ffeb130b455fec1bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 24 Sep 2026 05:52:41 +0200 Subject: [PATCH 5/5] test(runtime): state what the Object.create incremental-cycle test does and does not pin --- crates/perry-runtime/src/gc/tests/cycle_state.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/perry-runtime/src/gc/tests/cycle_state.rs b/crates/perry-runtime/src/gc/tests/cycle_state.rs index e79b438770..5a292815b5 100644 --- a/crates/perry-runtime/src/gc/tests/cycle_state.rs +++ b/crates/perry-runtime/src/gc/tests/cycle_state.rs @@ -1659,15 +1659,19 @@ fn full_cycle_object_create_after_root_scan_preserves_prototype_via_owner() { "full cycle should keep root barriers active after root scan" ); - // The owner is created after the root scan and the prototype is held - // ONLY by its meta record: the meta-slot store's barrier must shade it. + // The owner is created after the root scan, and afterwards the prototype + // is held only through the owner's meta record. Object.create roots the + // prototype in a runtime handle before allocating, and that root store's + // barrier shades it during the cycle, so this also passes with the + // meta-slot barrier removed (sabotage-checked). What it pins is the + // end-to-end Object.create path under an incremental full cycle. let created = crate::object::js_object_create(f64::from_bits(ptr_bits(child as usize))); js_shadow_slot_set(0, created.to_bits()); run_cycle_in_single_unit_steps(&mut state); assert!( malloc_user_ptr_tracked(child as *mut u8), - "an Object.create prototype stored after root scan must survive via its owner's barrier" + "an Object.create prototype linked after root scan must survive the cycle" ); let created = f64::from_bits(js_shadow_slot_get(0)); assert_eq!(