diff --git a/changelog.d/11015-integrity-shape-transition.md b/changelog.d/11015-integrity-shape-transition.md new file mode 100644 index 0000000000..8aae0c7bd0 --- /dev/null +++ b/changelog.d/11015-integrity-shape-transition.md @@ -0,0 +1,10 @@ +`Object.preventExtensions`, `Object.seal`, and `Object.freeze` now publish a new +ShapeId when they change an ordinary object's integrity flags. Previously, +`preventExtensions` left the shape unchanged, so a future shape-keyed property +add cache could reuse an edge learned while the object was extensible. Seal and +freeze only changed the shape indirectly when they updated an existing key's +descriptor, leaving keyless objects with the same gap. + +Repeated calls with no new flag changes preserve the current ShapeId. A runtime +test covers all three operations on keyless objects and verifies that sibling +objects retain their original shape. diff --git a/crates/perry-runtime/src/object/object_ops_frozen.rs b/crates/perry-runtime/src/object/object_ops_frozen.rs index 835cb1a549..2b9baba4c6 100644 --- a/crates/perry-runtime/src/object/object_ops_frozen.rs +++ b/crates/perry-runtime/src/object/object_ops_frozen.rs @@ -149,6 +149,18 @@ unsafe fn integrity_flags_are_writable(obj: *const ObjectHeader) -> bool { !obj.is_null() && crate::value::addr_class::try_read_tracked_gc_header(obj as usize).is_some() } +/// Extensibility is part of a shaped object's semantics. Retire its previous +/// ShapeId when an integrity flag changes so a shape-keyed add path cannot +/// reuse an edge learned while the object was extensible. +unsafe fn set_integrity_flags(obj: *mut ObjectHeader, flags: u16) { + let gc = gc_header_for(obj); + let added = (*gc)._reserved & flags != flags; + (*gc)._reserved |= flags; + if added { + shapes::transition_object_shape_semantics(obj); + } +} + #[no_mangle] pub extern "C" fn js_object_freeze(obj_value: f64) -> f64 { crate::array::subclass_elements::deopt_value(obj_value); @@ -167,10 +179,12 @@ pub extern "C" fn js_object_freeze(obj_value: f64) -> f64 { // no-op-and-return-the-value behaviour for a rejected receiver is // unchanged (`test_gap_handle_band_object_ops` `Object.freeze(blob)`). if integrity_flags_are_writable(obj) { - let gc = gc_header_for(obj); - (*gc)._reserved |= crate::gc::OBJ_FLAG_FROZEN - | crate::gc::OBJ_FLAG_SEALED - | crate::gc::OBJ_FLAG_NO_EXTEND; + set_integrity_flags( + obj, + crate::gc::OBJ_FLAG_FROZEN + | crate::gc::OBJ_FLAG_SEALED + | crate::gc::OBJ_FLAG_NO_EXTEND, + ); // TypedArray receivers are NOT `ObjectHeader`s — the key walk // below would read a garbage `keys_array` off the TA header and // can fault depending on heap layout. The GC flags above are the @@ -277,8 +291,10 @@ pub extern "C" fn js_object_seal(obj_value: f64) -> f64 { unsafe { let obj = extract_obj_ptr(obj_value); if integrity_flags_are_writable(obj) { - let gc = gc_header_for(obj); - (*gc)._reserved |= crate::gc::OBJ_FLAG_SEALED | crate::gc::OBJ_FLAG_NO_EXTEND; + set_integrity_flags( + obj, + crate::gc::OBJ_FLAG_SEALED | crate::gc::OBJ_FLAG_NO_EXTEND, + ); } } return obj_value; @@ -286,8 +302,10 @@ pub extern "C" fn js_object_seal(obj_value: f64) -> f64 { unsafe { let obj = extract_obj_ptr(obj_value); if integrity_flags_are_writable(obj) { - let gc = gc_header_for(obj); - (*gc)._reserved |= crate::gc::OBJ_FLAG_SEALED | crate::gc::OBJ_FLAG_NO_EXTEND; + set_integrity_flags( + obj, + crate::gc::OBJ_FLAG_SEALED | crate::gc::OBJ_FLAG_NO_EXTEND, + ); // TypedArray receivers: GC flags only — see `js_object_freeze`. if crate::typedarray::lookup_typed_array_kind(obj as usize).is_some() || crate::typedarray_props::typed_array_addr_from_value(obj_value).is_some() @@ -389,8 +407,7 @@ pub extern "C" fn js_object_prevent_extensions(obj_value: f64) -> f64 { crate::typedarray_props::typed_array_mark_no_extend(owner); return obj_value; } - let gc = gc_header_for(obj); - (*gc)._reserved |= crate::gc::OBJ_FLAG_NO_EXTEND; + set_integrity_flags(obj, crate::gc::OBJ_FLAG_NO_EXTEND); } } obj_value diff --git a/crates/perry-runtime/src/object/shape_rules_tests.rs b/crates/perry-runtime/src/object/shape_rules_tests.rs index 61704a990f..132a00f28b 100644 --- a/crates/perry-runtime/src/object/shape_rules_tests.rs +++ b/crates/perry-runtime/src/object/shape_rules_tests.rs @@ -80,6 +80,43 @@ fn accessor() -> AccessorDescriptor { const DEFAULT_ATTRS: PropertyAttrs = PropertyAttrs::new(true, true, true); const FROZEN_ATTRS: PropertyAttrs = PropertyAttrs::new(false, true, false); +/// Keyless receivers expose the flag transition directly: no descriptor +/// install can incidentally mint a successor shape for these operations. +#[test] +fn rule1_integrity_flags_transition_keyless_shapes() { + let _lock = crate::gc::global_side_table_test_lock(); + for (name, operation) in [ + ( + "preventExtensions", + super::js_object_prevent_extensions as extern "C" fn(f64) -> f64, + ), + ("seal", super::js_object_seal), + ("freeze", super::js_object_freeze), + ] { + unsafe { + let obj = shaped_object(&[]); + let sibling = shaped_object(&[]); + let before = shapes::object_shape_stamp(obj); + assert_eq!(before, shapes::object_shape_stamp(sibling)); + let value = crate::value::js_nanbox_pointer(obj as i64); + operation(value); + let after = shapes::object_shape_stamp(obj); + assert_ne!(before, after, "{name} must retire the extensible shape"); + assert_eq!( + before, + shapes::object_shape_stamp(sibling), + "{name} must not change a sibling's shape" + ); + operation(value); + assert_eq!( + after, + shapes::object_shape_stamp(obj), + "repeated {name} must not mint another shape for unchanged flags" + ); + } + } +} + #[test] fn rule1_set_property_attrs_transitions() { assert_shape_moves("set_property_attrs", |addr| {