From 18d124f5947deb7f363818bc4699c82c8836827c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 29 Aug 2026 15:58:19 +0000 Subject: [PATCH 1/5] fix(runtime): reserve iterator raw-field floor so own next patches stop corrupting state (#9019) A by-name property write on a builtin collection iterator object derived its field index from the (empty) keys array, so the first user property landed at field 0 and overwrote the backing-collection pointer. it.foo = 1 made iteration report done immediately; it.next = fn made the next builtin advance dereference the closure as a SetHeader and SIGSEGV under for...of. Storage: the first by-name append to a reserved-layout receiver (array/ map/set/string/buffer/regexp iterators, iterator helpers) now seeds the keys array with floor leading tombstones (the #9038 hole marker every lookup/enumeration/delete path already skips), so user keys append past the raw internal fields; the hole-squeeze compaction preserves the reserved prefix. Dispatch: the class-id iterator dispatchers honor an own next before the builtin advance (non-callable own values throw per IteratorNext), while the canonical prototype thunks keep running the builtin algorithm so a patch delegating to its bound original cannot re-enter itself. The fused for...of arms validate the iterator result, and the stored-closure drain paths bind this to the iterator per Call(next, iterator). --- crates/perry-runtime/src/array/iter_object.rs | 31 +- crates/perry-runtime/src/array/iterator.rs | 40 ++- crates/perry-runtime/src/array/mod.rs | 1 + crates/perry-runtime/src/buffer/iter.rs | 7 + .../src/collection_iter_object.rs | 60 +++- crates/perry-runtime/src/iterator_helpers.rs | 11 +- .../perry-runtime/src/object/delete_rest.rs | 28 +- .../src/object/field_set_by_name/tail.rs | 23 +- .../src/object/iterator_prototypes.rs | 46 ++- crates/perry-runtime/src/object/mod.rs | 2 + .../src/object/reserved_floor.rs | 288 ++++++++++++++++++ crates/perry-runtime/src/object/shapes.rs | 8 +- crates/perry-runtime/src/regex.rs | 1 + crates/perry-runtime/src/regex/match_all.rs | 27 ++ .../perry-runtime/src/string/iter_object.rs | 28 +- crates/perry-runtime/src/string/mod.rs | 1 + test-files/test_gap_iterator_patched_next.ts | 117 +++++++ 17 files changed, 680 insertions(+), 39 deletions(-) create mode 100644 crates/perry-runtime/src/object/reserved_floor.rs create mode 100644 test-files/test_gap_iterator_patched_next.ts diff --git a/crates/perry-runtime/src/array/iter_object.rs b/crates/perry-runtime/src/array/iter_object.rs index a7ffaab377..f15c66f964 100644 --- a/crates/perry-runtime/src/array/iter_object.rs +++ b/crates/perry-runtime/src/array/iter_object.rs @@ -620,6 +620,26 @@ unsafe fn make_pair_array(idx: u32, value: f64) -> f64 { pub unsafe fn dispatch_array_iterator_method( iter_obj: *mut ObjectHeader, method_name: &str, +) -> f64 { + dispatch_array_iterator_method_inner(iter_obj, method_name, true) +} + +/// Builtin advance only — the canonical prototype thunk's entry (#9019): +/// `%ArrayIteratorPrototype%.next.call(it)` (or a pre-patch `.bind(it)`) +/// runs the builtin algorithm even when the instance carries an own patched +/// `next`, or a patch delegating to the bound original would re-enter +/// itself forever. +pub(crate) unsafe fn dispatch_array_iterator_method_builtin( + iter_obj: *mut ObjectHeader, + method_name: &str, +) -> f64 { + dispatch_array_iterator_method_inner(iter_obj, method_name, false) +} + +unsafe fn dispatch_array_iterator_method_inner( + iter_obj: *mut ObjectHeader, + method_name: &str, + honor_override: bool, ) -> f64 { // #7475: the raw `iter_obj` parameter is not a GC root, and this function // allocates in several places — `js_object_set_field` (shape transition / @@ -645,10 +665,13 @@ pub unsafe fn dispatch_array_iterator_method( }; match method_name { "next" => { - if let Some(result) = - crate::object::call_overridden_iterator_next(iter_obj(), ARRAY_ITERATOR_CLASS_ID) - { - return result; + if honor_override { + if let Some(result) = crate::object::call_overridden_iterator_next( + iter_obj(), + ARRAY_ITERATOR_CLASS_ID, + ) { + return result; + } } if kind == KIND_VALUES_NULL_DONE { let epoch_ptr = js_nanbox_get_pointer(f64::from_bits( diff --git a/crates/perry-runtime/src/array/iterator.rs b/crates/perry-runtime/src/array/iterator.rs index 14ccb0147f..ecc4571779 100644 --- a/crates/perry-runtime/src/array/iterator.rs +++ b/crates/perry-runtime/src/array/iterator.rs @@ -1378,6 +1378,10 @@ pub(crate) fn sync_iterator_to_array_if_not_async(iter_f64: f64) -> Option<*mut let next_ptr = if next_val.is_undefined() { std::ptr::null::() } else { + // #9019: same non-callable own `next` guard as `js_iterator_to_array`. + if !is_callable_value(next_f64) { + crate::closure::throw_not_callable(); + } js_nanbox_get_pointer(next_f64) as *const closure::ClosureHeader }; let use_method_dispatch = next_ptr.is_null(); @@ -1403,7 +1407,12 @@ pub(crate) fn sync_iterator_to_array_if_not_async(iter_f64: f64) -> Option<*mut ) } } else { - closure::js_closure_call1(next_ptr, f64::from_bits(TAG_UNDEFINED)) + // Call(next, iterator) — bind `this` like `js_iterator_to_array` + // does for its stored-closure path (#9019). + let prev_this = crate::object::js_implicit_this_set(iter_f64); + let r = closure::js_closure_call1(next_ptr, f64::from_bits(TAG_UNDEFINED)); + crate::object::js_implicit_this_set(prev_this); + r }; if crate::promise::js_value_is_promise(step) != 0 { return None; @@ -1540,6 +1549,14 @@ pub extern "C" fn js_iterator_to_array(iter_f64: f64) -> *mut ArrayHeader { let next_ptr = if next_val.is_undefined() { std::ptr::null::() } else { + // #9019: an own `next` that is not a callable closure must throw + // (IteratorNext is GetV + Call), not be reinterpreted as a + // `ClosureHeader` — a builtin iterator can now carry a user-assigned + // own `next` of any type, and calling through a number's payload + // bits crashes. + if !is_callable_value(next_f64) { + crate::closure::throw_not_callable(); + } js_nanbox_get_pointer(next_f64) as *const closure::ClosureHeader }; // #321: some iterators (perry's runtime array iterator with @@ -1583,10 +1600,16 @@ pub extern "C" fn js_iterator_to_array(iter_f64: f64) -> *mut ArrayHeader { ) } } else { - closure::js_closure_call1( + // Call(next, iterator): bind `this` for the stored-closure path + // exactly like `js_iterator_next_result` — a user-assigned + // `it.next = function () { … }` may read `this` (#9019). + let prev_this = crate::object::js_implicit_this_set(iter_h.get_nanbox_f64()); + let r = closure::js_closure_call1( js_nanbox_get_pointer(next_h.get_nanbox_f64()) as *const closure::ClosureHeader, f64::from_bits(TAG_UNDEFINED), - ) + ); + crate::object::js_implicit_this_set(prev_this); + r }; // IteratorNext (ECMA-262 §7.4.2 step 3): if Type(result) is not // Object, throw a TypeError. `is_pointer()` is true only for @@ -1672,6 +1695,10 @@ fn js_async_iterator_to_array(iter_f64: f64) -> *mut ArrayHeader { let next_ptr = if next_val.is_undefined() { std::ptr::null::() } else { + // #9019: same non-callable own `next` guard as `js_iterator_to_array`. + if !is_callable_value(next_f64) { + crate::closure::throw_not_callable(); + } js_nanbox_get_pointer(next_f64) as *const closure::ClosureHeader }; let use_method_dispatch = next_ptr.is_null(); @@ -1696,7 +1723,12 @@ fn js_async_iterator_to_array(iter_f64: f64) -> *mut ArrayHeader { ) } } else { - closure::js_closure_call1(next_ptr, f64::from_bits(TAG_UNDEFINED)) + // Call(next, iterator) — bind `this` for the stored-closure path + // (#9019), mirroring `js_iterator_to_array`. + let prev_this = crate::object::js_implicit_this_set(iter_f64); + let r = closure::js_closure_call1(next_ptr, f64::from_bits(TAG_UNDEFINED)); + crate::object::js_implicit_this_set(prev_this); + r }; let Some(step_result) = settled_promise_value(step) else { break; diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index 4bfada28f3..5032a8313d 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -154,6 +154,7 @@ pub use self::iter_methods::{ js_array_map_discard, js_array_reduce, js_array_some, js_array_some_captureless, js_array_to_locale_string, js_validate_array_callback, js_validate_array_map_callback, }; +pub(crate) use self::iter_object::dispatch_array_iterator_method_builtin; pub use self::iter_object::{ arguments_values_iter, array_entries_iter, array_keys_iter, array_values_iter, array_values_iter_null_done, dispatch_array_iterator_method, js_array_entries_iter_obj, diff --git a/crates/perry-runtime/src/buffer/iter.rs b/crates/perry-runtime/src/buffer/iter.rs index 2a36feed3a..d2e56aa13e 100644 --- a/crates/perry-runtime/src/buffer/iter.rs +++ b/crates/perry-runtime/src/buffer/iter.rs @@ -110,6 +110,13 @@ pub unsafe fn dispatch_buffer_iterator_method( ) -> f64 { match method_name { "next" => { + // #9019: an own `next` assigned onto the iterator instance wins + // over the builtin advance, exactly as on the Map/Set path. + if let Some(result) = + crate::object::call_overridden_iterator_next(iter_obj, BUFFER_ITERATOR_CLASS_ID) + { + return result; + } // Field 0: backing buffer pointer (NaN-boxed). let backing_field = js_object_get_field(iter_obj, 0); let backing_f64 = f64::from_bits(backing_field.bits()); diff --git a/crates/perry-runtime/src/collection_iter_object.rs b/crates/perry-runtime/src/collection_iter_object.rs index 532ad7fe1b..b6319473ff 100644 --- a/crates/perry-runtime/src/collection_iter_object.rs +++ b/crates/perry-runtime/src/collection_iter_object.rs @@ -238,23 +238,39 @@ fn next_read_index(cursor: u32, last_key_in_place: bool, find_last: impl FnOnce( /// Dispatch `.next()` / `[Symbol.iterator]()` on a Map iterator object. pub unsafe fn dispatch_map_iterator_method(iter_obj: *mut ObjectHeader, method_name: &str) -> f64 { - dispatch_map_iterator_method_emit(iter_obj, method_name, false) + dispatch_map_iterator_method_emit(iter_obj, method_name, false, true) +} + +/// Builtin advance only — the canonical prototype thunk's entry (#9019). +/// `%MapIteratorPrototype%.next.call(it)` (including a `.bind(it)` taken +/// before a patch was installed) must run the builtin algorithm even when +/// the instance carries an own patched `next`: honoring the override there +/// would make a patch that delegates to the bound original re-enter itself +/// forever, and it is also not what the spec function does. +pub(crate) unsafe fn dispatch_map_iterator_method_builtin( + iter_obj: *mut ObjectHeader, + method_name: &str, +) -> f64 { + dispatch_map_iterator_method_emit(iter_obj, method_name, false, false) } unsafe fn dispatch_map_iterator_method_emit( iter_obj: *mut ObjectHeader, method_name: &str, emit_cached: bool, + honor_override: bool, ) -> f64 { let scope = crate::gc::RuntimeHandleScope::new(); let iter_h = scope.root_nanbox_f64(js_nanbox_pointer(iter_obj as i64)); let iter_obj = || js_nanbox_get_pointer(iter_h.get_nanbox_f64()) as *mut ObjectHeader; match method_name { "next" => { - if let Some(result) = - crate::object::call_overridden_iterator_next(iter_obj(), MAP_ITERATOR_CLASS_ID) - { - return result; + if honor_override { + if let Some(result) = + crate::object::call_overridden_iterator_next(iter_obj(), MAP_ITERATOR_CLASS_ID) + { + return result; + } } let backing = f64::from_bits(js_object_get_field(iter_obj(), 0).bits()); let map_h = scope.root_nanbox_f64(backing); @@ -317,23 +333,34 @@ unsafe fn dispatch_map_iterator_method_emit( /// Dispatch `.next()` / `[Symbol.iterator]()` on a Set iterator object. pub unsafe fn dispatch_set_iterator_method(iter_obj: *mut ObjectHeader, method_name: &str) -> f64 { - dispatch_set_iterator_method_emit(iter_obj, method_name, false) + dispatch_set_iterator_method_emit(iter_obj, method_name, false, true) +} + +/// Builtin advance only — see [`dispatch_map_iterator_method_builtin`]. +pub(crate) unsafe fn dispatch_set_iterator_method_builtin( + iter_obj: *mut ObjectHeader, + method_name: &str, +) -> f64 { + dispatch_set_iterator_method_emit(iter_obj, method_name, false, false) } unsafe fn dispatch_set_iterator_method_emit( iter_obj: *mut ObjectHeader, method_name: &str, emit_cached: bool, + honor_override: bool, ) -> f64 { let scope = crate::gc::RuntimeHandleScope::new(); let iter_h = scope.root_nanbox_f64(js_nanbox_pointer(iter_obj as i64)); let iter_obj = || js_nanbox_get_pointer(iter_h.get_nanbox_f64()) as *mut ObjectHeader; match method_name { "next" => { - if let Some(result) = - crate::object::call_overridden_iterator_next(iter_obj(), SET_ITERATOR_CLASS_ID) - { - return result; + if honor_override { + if let Some(result) = + crate::object::call_overridden_iterator_next(iter_obj(), SET_ITERATOR_CLASS_ID) + { + return result; + } } let backing = f64::from_bits(js_object_get_field(iter_obj(), 0).bits()); let set_h = scope.root_nanbox_f64(backing); @@ -448,11 +475,20 @@ pub unsafe extern "C-unwind" fn js_for_of_next(iter: f64) -> f64 { if header.obj_type == crate::gc::GC_TYPE_OBJECT { let obj = raw as *mut ObjectHeader; let class_id = (*obj).class_id; + // Spec IteratorNext validation applies on the fused arms + // too: a builtin advance always returns an object, but a + // patched own `next` (#9019) can return anything, and + // `for…of` must throw the same TypeError the generic arm + // throws rather than hand the desugar a primitive. if class_id == MAP_ITERATOR_CLASS_ID { - return dispatch_map_iterator_method_emit(obj, "next", true); + return crate::symbol::js_iterator_result_validate( + dispatch_map_iterator_method_emit(obj, "next", true, true), + ); } if class_id == SET_ITERATOR_CLASS_ID { - return dispatch_set_iterator_method_emit(obj, "next", true); + return crate::symbol::js_iterator_result_validate( + dispatch_set_iterator_method_emit(obj, "next", true, true), + ); } } } diff --git a/crates/perry-runtime/src/iterator_helpers.rs b/crates/perry-runtime/src/iterator_helpers.rs index f92848b407..dc75b72390 100644 --- a/crates/perry-runtime/src/iterator_helpers.rs +++ b/crates/perry-runtime/src/iterator_helpers.rs @@ -445,7 +445,16 @@ pub unsafe fn dispatch_iterator_helper_method( }; match method_name { - "next" => helper_next(obj), + // #9019: an own `next` assigned onto the helper instance wins over + // the builtin advance, exactly as on the Map/Set path. + "next" => { + if let Some(result) = + crate::object::call_overridden_iterator_next(obj, ITERATOR_HELPER_CLASS_ID) + { + return result; + } + helper_next(obj) + } "Symbol.iterator" | "@@iterator" => self_f64, "return" | "throw" => make_iter_result(JSValue::undefined(), true), // Lazy helpers — return a new helper wrapping `self`. diff --git a/crates/perry-runtime/src/object/delete_rest.rs b/crates/perry-runtime/src/object/delete_rest.rs index 60e8a5efe8..b0070c966a 100644 --- a/crates/perry-runtime/src/object/delete_rest.rs +++ b/crates/perry-runtime/src/object/delete_rest.rs @@ -398,7 +398,15 @@ pub extern "C" fn js_object_delete_field( // Threshold: squeeze every hole plus this key in one pass, // then continue through the ordinary compaction bookkeeping // is unnecessary — the squeeze does its own. - squeeze_holes_and_delete(obj, keys, i, key_count, alloc_limit, field_count); + squeeze_holes_and_delete( + obj, + keys, + i, + key_count, + alloc_limit, + field_count, + crate::object::reserved_slot_floor_for_class_id((*obj).class_id) as usize, + ); return 1; } } @@ -1193,12 +1201,20 @@ unsafe fn squeeze_holes_and_delete( key_count: usize, alloc_limit: usize, field_count: u32, + // #9019: a reserved-layout receiver's leading `floor` slots are + // STRUCTURAL tombstones guarding its raw internal fields — squeezing + // them would slide user keys back under the floor and re-open the + // field-0 alias. They are exempt from compaction; only churn holes at + // or past the floor are squeezed. (`delete_slot` is always >= floor + // there: the reserved slots hold no key a delete could match.) + reserved_floor: usize, ) { let keys = keys as *mut crate::ArrayHeader; let elements = (keys as *mut u8).add(std::mem::size_of::()) as *mut f64; let fields_ptr = (obj as *mut u8).add(std::mem::size_of::()) as *mut u64; - let mut out = 0usize; - for s in 0..key_count { + let floor = reserved_floor.min(key_count); + let mut out = floor; + for s in floor..key_count { let kv = std::ptr::read(elements.add(s)); if s == delete_slot || kv.to_bits() == crate::value::TAG_HOLE { continue; @@ -1238,7 +1254,9 @@ unsafe fn squeeze_holes_and_delete( set_object_live_slot_count(obj, std::cmp::min(out, alloc_limit) as u32); // Slots moved: the per-array key index and any stale descriptors for the // pre-squeeze states are wrong now. Drop the index (rebuilt on demand) - // and publish the squeezed shape at hole_count = 0. + // and publish the squeezed shape at exactly the surviving hole count — + // zero for an ordinary receiver, the structural reserved floor (#9019) + // for an iterator-family one. crate::object::shapes::shape_drop(keys); - super::shapes::publish_object_shape_holes(obj, 0); + super::shapes::publish_object_shape_holes(obj, floor as u32); } diff --git a/crates/perry-runtime/src/object/field_set_by_name/tail.rs b/crates/perry-runtime/src/object/field_set_by_name/tail.rs index 5d5d80ae42..b5feb8eedb 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/tail.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/tail.rs @@ -435,7 +435,7 @@ pub(crate) fn set_field_by_name_object_tail( obj_flags & (crate::gc::OBJ_FLAG_SEALED | crate::gc::OBJ_FLAG_NO_EXTEND) != 0; let record_array_tail = crate::array::is_array_subclass_class_id((*obj).class_id); - let keys = crate::object::object_keys_array(obj); + let mut keys = crate::object::object_keys_array(obj); // Validate keys_array is a real heap pointer or null. if !keys.is_null() { @@ -445,6 +445,27 @@ pub(crate) fn set_field_by_name_object_tail( } } + // #9019: a built-in iterator receiver (Set/Map/array/string/… + // iterator object) keeps its internal state in RAW numbered fields + // the keys array does not describe, so the append below would hand + // its first user key field index 0 and overwrite the backing + // collection — a later builtin `.next()` then dereferences the + // stored value as a collection header. Seed the reserved-floor keys + // (leading tombstones) first; the ordinary flow — including the + // transition-cache fast path, whose `prev_shape_id` is read AFTER + // this — then appends at the floor. Seeding allocates, so every raw + // local is re-read through its handle. + if keys.is_null() + && crate::object::reserved_slot_floor_for_class_id((*obj).class_id) != 0 + && crate::object::ensure_reserved_floor_keys(obj) + { + obj = obj_handle.get_raw_mut_ptr::(); + key = key_handle.get_raw_const_ptr::(); + value = value_handle.get_nanbox_f64(); + interned_key = interned_key_handle.get_raw_const_ptr::(); + keys = crate::object::object_keys_array(obj); + } + let mut prev_keys_usize = keys as usize; let prev_shape_id = super::shapes::object_shape_stamp(obj); diff --git a/crates/perry-runtime/src/object/iterator_prototypes.rs b/crates/perry-runtime/src/object/iterator_prototypes.rs index 71a34a4c2b..428c462c35 100644 --- a/crates/perry-runtime/src/object/iterator_prototypes.rs +++ b/crates/perry-runtime/src/object/iterator_prototypes.rs @@ -79,22 +79,27 @@ unsafe fn dispatch_on_implicit_this(method: &str) -> f64 { return brand_type_error(method); } let class_id = (*obj).class_id; + // The `_builtin` variants skip the own-`next` override probe (#9019): + // these thunks ARE the canonical prototype `next` functions, so invoking + // one directly (`proto.next.call(it)`, or a `.bind(it)` taken before a + // patch landed) must run the builtin algorithm — probing here would send + // a patch that delegates to its bound original into infinite recursion. match class_id { crate::array::ARRAY_ITERATOR_CLASS_ID => { - crate::array::dispatch_array_iterator_method(obj, method) + crate::array::dispatch_array_iterator_method_builtin(obj, method) } crate::collection_iter_object::MAP_ITERATOR_CLASS_ID => { - crate::collection_iter_object::dispatch_map_iterator_method(obj, method) + crate::collection_iter_object::dispatch_map_iterator_method_builtin(obj, method) } crate::collection_iter_object::SET_ITERATOR_CLASS_ID => { - crate::collection_iter_object::dispatch_set_iterator_method(obj, method) + crate::collection_iter_object::dispatch_set_iterator_method_builtin(obj, method) } crate::string::STRING_ITERATOR_CLASS_ID => { - crate::string::dispatch_string_iterator_method(obj, method) + crate::string::dispatch_string_iterator_method_builtin(obj, method) } #[cfg(feature = "regex-engine")] crate::regex::REGEXP_STRING_ITERATOR_CLASS_ID => { - crate::regex::dispatch_regexp_string_iterator_method(obj, method) + crate::regex::dispatch_regexp_string_iterator_method_builtin(obj, method) } _ => brand_type_error(method), } @@ -357,6 +362,37 @@ pub(crate) unsafe fn call_overridden_iterator_next( let scope = crate::gc::RuntimeHandleScope::new(); let iter = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(iter_obj as i64)); let previous = scope.root_nanbox_f64(super::js_implicit_this_get()); + // #9019: an OWN `next` (`it.next = fn`, stored past the reserved floor + // by `object/reserved_floor.rs`) shadows the prototype thunk and exists + // independently of the tower, so probe it BEFORE the tower-null + // early-out — the assignment alone materializes nothing. For every + // unpatched iterator the probe is one descriptor lookup ending at a + // null keys edge. A PRESENT own value that is not a closure throws, + // matching IteratorNext's GetV+Call — it must never fall through to the + // builtin advance, which would ignore the patch the user installed. + let own = super::js_object_get_own_field_or_undef(iter.get_nanbox_f64(), b"next".as_ptr(), 4); + if own.to_bits() != crate::value::TAG_UNDEFINED { + if !JSValue::from_bits(own.to_bits()).is_pointer() { + crate::closure::throw_not_callable(); + } + let own_raw = crate::value::js_nanbox_get_pointer(own); + // `is_closure_ptr` self-validates the address (handle band + heap + // floor + magic probe), so a null or mis-boxed value throws rather + // than faulting. + if !crate::closure::is_closure_ptr(own_raw as usize) { + crate::closure::throw_not_callable(); + } + let method = scope.root_nanbox_f64(own); + super::js_implicit_this_set(iter.get_nanbox_f64()); + let result = crate::exception::js_call_catching(|| { + crate::closure::js_native_call_value(method.get_nanbox_f64(), std::ptr::null(), 0) + }); + super::js_implicit_this_set(previous.get_nanbox_f64()); + return match result { + Ok(value) => Some(value), + Err(error) => crate::exception::js_throw(error), + }; + } // An override can only be installed through the prototype OBJECT, and the // only way user code obtains that object is `Object.getPrototypeOf(iter)` // (or a direct prototype write), both of which materialize the tower. A diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index e90e865796..b79ac44dea 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -144,6 +144,8 @@ pub(crate) mod shapes; pub(crate) use shapes::ShapeTable; mod prototype_helpers; mod reflect_support; +mod reserved_floor; +pub(crate) use reserved_floor::{ensure_reserved_floor_keys, reserved_slot_floor_for_class_id}; mod regex_proto_thunks; // #6812 object-owned overflow storage + the legacy thread-local side table. // Split out of this file to stay under the 2000-line CI cap; the sibling diff --git a/crates/perry-runtime/src/object/reserved_floor.rs b/crates/perry-runtime/src/object/reserved_floor.rs new file mode 100644 index 0000000000..729ba5f605 --- /dev/null +++ b/crates/perry-runtime/src/object/reserved_floor.rs @@ -0,0 +1,288 @@ +//! Reserved raw-field floors for built-in iterator objects (#9019). +//! +//! The built-in iterator families (array / map / set / string / buffer / +//! regexp-string iterators, iterator helpers) are ordinary `GC_TYPE_OBJECT` +//! allocations whose internal state — backing collection, cursor, kind, +//! cached result — lives in RAW numbered fields written with +//! `js_object_set_field`, while their keys array starts EMPTY. The by-name +//! append path derives a new key's field index from the keys array, so the +//! first user property (`it.next = fn`, `it.foo = 1`) landed at field index +//! 0 and overwrote the backing-collection pointer. The next builtin +//! `.next()` then read the stored value as a `SetHeader`/`MapHeader` +//! pointer: a NaN-boxed number there reads as a null-ish backing (the +//! iterator silently reports `done: true`), a closure there is dereferenced +//! as a collection header and SIGSEGVs. +//! +//! The fix keeps the keys-position ↔ field-index correspondence every +//! by-name path relies on: before the FIRST by-name append to such a +//! receiver, seed its keys array with `floor` leading tombstones +//! (`TAG_HOLE`, the #9038 hole-delete marker every lookup / enumeration / +//! delete path already skips). User keys then append from `floor` upward — +//! past every raw internal field — and land in the ordinary inline/overflow +//! storage. Unpatched iterators never pay: the seed runs only when user +//! code actually adds a named property. + +use super::ObjectHeader; +use crate::array::ArrayHeader; + +/// One past the highest raw numbered field the family's dispatch touches. +/// `0` for every class id without a reserved raw-field layout. Keep each +/// entry in lock-step with the family's allocator/dispatcher: +/// +/// * array: `array/iter_object.rs` (fields 0..4: backing, cursor, kind, +/// snapshot len, epoch) +/// * map/set: `collection_iter_object.rs` (fields 0..5: backing, cursor, +/// kind, size-at-last-next, last key, cached fused result) +/// * string: `string/iter_object.rs` (fields 0..1) +/// * buffer: `buffer/iter.rs` (fields 0..2) +/// * regexp-string: `regex/match_all.rs` (fields 0..1) +/// * iterator helpers: `iterator_helpers.rs` (fields 0..3) +pub(crate) fn reserved_slot_floor_for_class_id(class_id: u32) -> u32 { + match class_id { + crate::array::ARRAY_ITERATOR_CLASS_ID => 5, + crate::collection_iter_object::MAP_ITERATOR_CLASS_ID + | crate::collection_iter_object::SET_ITERATOR_CLASS_ID => 6, + crate::string::STRING_ITERATOR_CLASS_ID => 2, + crate::buffer::BUFFER_ITERATOR_CLASS_ID => 3, + crate::regex::REGEXP_STRING_ITERATOR_CLASS_ID => 2, + crate::iterator_helpers::ITERATOR_HELPER_CLASS_ID => 4, + _ => 0, + } +} + +/// Install the reserved-floor keys array on a keys-less receiver whose class +/// id reserves raw field slots: `floor` leading `TAG_HOLE` slots, published +/// as a shape whose `hole_count` matches the physical holes, preserving the +/// birth descriptor's live inline-slot bound / kind / generation. Returns +/// `true` when the seed was installed — the keys allocation can trigger a +/// collection that MOVES the receiver, so the caller must re-read every raw +/// pointer (including its `keys` edge) through its handles afterwards. +/// +/// # Safety +/// `obj` must be a live `GC_TYPE_OBJECT` allocation. +pub(crate) unsafe fn ensure_reserved_floor_keys(obj: *mut ObjectHeader) -> bool { + let floor = reserved_slot_floor_for_class_id((*obj).class_id); + if floor == 0 || !super::object_keys_array(obj).is_null() { + return false; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_h = scope.root_raw_mut_ptr(obj); + let keys = crate::array::js_array_alloc(floor); + if keys.is_null() { + return false; + } + let keys_h = scope.root_raw_mut_ptr(keys); + keys_h.with_mut_ptr::(|keys| { + for i in 0..floor as usize { + crate::array::store_array_slot(keys, i, crate::value::TAG_HOLE); + } + (*keys).length = floor; + crate::array::rebuild_array_layout_exact(keys); + }); + let obj = obj_h.get_raw_mut_ptr::(); + let keys = keys_h.get_raw_mut_ptr::(); + // The keys edge is changing: retire any typed layout trained on the old + // (keys-less) representation before the successor is published, exactly + // like `set_object_keys_array_with_live`. + super::mark_object_dynamic_shape_unknown(obj); + stamp_reserved_floor_shape(obj, keys, floor) != 0 +} + +/// Publish + stamp the reserved-floor descriptor: `floor` keys, all of them +/// holes, at the receiver's current live inline-slot bound. Mirrors +/// `shapes::stamp_object_shape`'s lineage handling but carries an explicit +/// `hole_count` so the tombstone bookkeeping (delete thresholds, the +/// floor-aware squeeze in `delete_rest.rs`) sees the physical holes. +unsafe fn stamp_reserved_floor_shape( + obj: *mut ObjectHeader, + keys: *const ArrayHeader, + floor: u32, +) -> u32 { + use super::shapes; + if !shapes::shape_word_is_writable(obj) { + return 0; + } + let lineage = shapes::object_shape_descriptor(obj); + let live = lineage + .as_ref() + .map(|d| d.live_inline_slot_count) + .unwrap_or(0); + let generation = lineage.as_ref().map(|d| d.semantic_generation).unwrap_or(0); + let kind = lineage + .as_ref() + .map(|d| d.object_kind) + .unwrap_or(shapes::ShapeObjectKind::Ordinary); + crate::array::clear_array_subclass_named_prefix_token(obj); + let id = shapes::publish_shape_result(shapes::shape_descriptor_ensure_with_holes( + keys, floor, live, generation, kind, floor, + )); + (*obj).parent_class_id = id; + shapes::debug_assert_object_shape_parity_for_keys(obj, keys as *mut ArrayHeader); + id +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::object::{ + js_object_get_field, js_object_get_own_field_or_undef, js_object_set_field_by_name, + }; + use crate::value::{js_nanbox_get_pointer, js_nanbox_pointer, JSValue}; + + unsafe fn set_iter_with_10_20_30() -> *mut ObjectHeader { + let set = crate::set::js_set_alloc(4); + for v in [10.0f64, 20.0, 30.0] { + crate::set::js_set_add(set, v); + } + js_nanbox_get_pointer(js_nanbox_pointer( + crate::collection_iter_object::js_set_values_iter_obj(set), + )) as *mut ObjectHeader + } + + unsafe fn key(name: &str) -> *const crate::StringHeader { + crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32) + } + + unsafe fn result_value(res: f64) -> f64 { + f64::from_bits( + js_object_get_field(js_nanbox_get_pointer(res) as *mut ObjectHeader, 0).bits(), + ) + } + + unsafe fn result_done(res: f64) -> bool { + crate::value::js_is_truthy(f64::from_bits( + js_object_get_field(js_nanbox_get_pointer(res) as *mut ObjectHeader, 1).bits(), + )) != 0 + } + + /// #9019 regression, storage half: a by-name write on a Set iterator + /// must land PAST the raw internal fields. Pre-fix, `it.foo = 123` + /// stored 123 into field 0 — the backing-Set pointer — so the next + /// `.next()` reported `done: true` on a set with three live elements + /// (and a closure stored there was dereferenced as a `SetHeader` and + /// crashed). The fixture asserts the DISCRIMINATING quantity: field 0 + /// still holds the original backing value after the write. + #[test] + fn by_name_write_does_not_alias_the_backing_collection_field() { + unsafe { + let iter = set_iter_with_10_20_30(); + let backing_before = js_object_get_field(iter, 0).bits(); + assert!( + JSValue::from_bits(backing_before).is_pointer(), + "fixture must start with a pointer backing in field 0, or \ + every verdict below is vacuous" + ); + + js_object_set_field_by_name(iter, key("foo"), 123.0); + + assert_eq!( + js_object_get_field(iter, 0).bits(), + backing_before, + "the named write must not overwrite the backing-Set field" + ); + let own = js_object_get_own_field_or_undef( + js_nanbox_pointer(iter as i64), + b"foo".as_ptr(), + 3, + ); + assert_eq!(own, 123.0, "the named property must read back by name"); + + // And the iterator still walks all three elements. + let r1 = crate::collection_iter_object::dispatch_set_iterator_method(iter, "next"); + assert_eq!(result_value(r1), 10.0); + assert!(!result_done(r1)); + } + } + + /// #9019, dispatch half: an OWN `next` assigned onto the iterator must + /// win over the builtin advance on the class-id dispatch path (the same + /// path `for…of`'s fused `js_for_of_next` takes). + #[test] + fn own_next_shadows_the_builtin_advance() { + extern "C" fn patched_next(_c: *const crate::closure::ClosureHeader, _arg: f64) -> f64 { + unsafe { crate::iter_result::make_iter_result(JSValue::number(777.0), false) } + } + unsafe { + let iter = set_iter_with_10_20_30(); + let closure = crate::closure::js_closure_alloc(patched_next as *const u8, 0); + assert!(!closure.is_null()); + crate::closure::js_register_closure_arity(patched_next as *const u8, 0); + js_object_set_field_by_name( + iter, + key("next"), + crate::value::js_nanbox_pointer(closure as i64), + ); + + let r = crate::collection_iter_object::dispatch_set_iterator_method(iter, "next"); + assert_eq!( + result_value(r), + 777.0, + "an own patched next must drive the dispatch" + ); + assert!(!result_done(r)); + + // The backing set is untouched: removing the patch is not + // required for this fixture, but the raw fields must be intact. + assert!( + JSValue::from_bits(js_object_get_field(iter, 0).bits()).is_pointer(), + "backing field survived the patch write" + ); + } + } + + /// The seeded floor holds across every reserved family, and ordinary + /// receivers are untouched (`floor == 0`). + #[test] + fn floors_cover_every_reserved_family_and_nothing_else() { + assert_eq!( + reserved_slot_floor_for_class_id(crate::array::ARRAY_ITERATOR_CLASS_ID), + 5 + ); + assert_eq!( + reserved_slot_floor_for_class_id(crate::collection_iter_object::MAP_ITERATOR_CLASS_ID), + 6 + ); + assert_eq!( + reserved_slot_floor_for_class_id(crate::collection_iter_object::SET_ITERATOR_CLASS_ID), + 6 + ); + assert_eq!( + reserved_slot_floor_for_class_id(crate::string::STRING_ITERATOR_CLASS_ID), + 2 + ); + assert_eq!( + reserved_slot_floor_for_class_id(crate::buffer::BUFFER_ITERATOR_CLASS_ID), + 3 + ); + assert_eq!( + reserved_slot_floor_for_class_id(crate::regex::REGEXP_STRING_ITERATOR_CLASS_ID), + 2 + ); + assert_eq!( + reserved_slot_floor_for_class_id(crate::iterator_helpers::ITERATOR_HELPER_CLASS_ID), + 4 + ); + assert_eq!(reserved_slot_floor_for_class_id(0), 0); + assert_eq!(reserved_slot_floor_for_class_id(42), 0); + } + + /// Deleting the patch tombstones it without disturbing the reserved + /// prefix, and the builtin advance resumes. + #[test] + fn delete_of_a_user_key_keeps_the_reserved_prefix() { + unsafe { + let iter = set_iter_with_10_20_30(); + let backing_before = js_object_get_field(iter, 0).bits(); + js_object_set_field_by_name(iter, key("foo"), 123.0); + let deleted = crate::object::js_object_delete_field(iter, key("foo")); + assert_eq!(deleted, 1, "delete must report success"); + assert_eq!( + js_object_get_field(iter, 0).bits(), + backing_before, + "delete must not disturb the backing field" + ); + let r = crate::collection_iter_object::dispatch_set_iterator_method(iter, "next"); + assert_eq!(result_value(r), 10.0); + } + } +} diff --git a/crates/perry-runtime/src/object/shapes.rs b/crates/perry-runtime/src/object/shapes.rs index d34145c86d..a4a8f67c3b 100644 --- a/crates/perry-runtime/src/object/shapes.rs +++ b/crates/perry-runtime/src/object/shapes.rs @@ -517,8 +517,10 @@ fn shape_descriptor_ensure_with_generation( /// [`shape_descriptor_ensure_with_generation`] with an explicit tombstone /// count — the publish half of an O(1) hole-delete, which must mint a shape -/// identity distinct from every hole state of the same array. -fn shape_descriptor_ensure_with_holes( +/// identity distinct from every hole state of the same array. Also the mint +/// for #9019's reserved-floor seed (`object/reserved_floor.rs`), whose keys +/// array is BORN with `floor` leading holes. +pub(crate) fn shape_descriptor_ensure_with_holes( keys: *const ArrayHeader, logical_key_count: u32, live_inline_slot_count: u32, @@ -606,7 +608,7 @@ fn shape_descriptor_error_abort(error: ShapeDescriptorError) -> ! { } #[inline] -fn publish_shape_result(result: Result) -> u32 { +pub(crate) fn publish_shape_result(result: Result) -> u32 { match result { Ok(id) => id, Err(error) => shape_descriptor_error_abort(error), diff --git a/crates/perry-runtime/src/regex.rs b/crates/perry-runtime/src/regex.rs index 55bd468450..7f152a5ce7 100644 --- a/crates/perry-runtime/src/regex.rs +++ b/crates/perry-runtime/src/regex.rs @@ -65,6 +65,7 @@ use grammar::{ has_unicode_forbidden_legacy_escape, has_unicode_forbidden_pattern, js_regex_to_rust, }; #[cfg(feature = "regex-engine")] +pub(crate) use match_all::dispatch_regexp_string_iterator_method_builtin; pub use match_all::{ dispatch_regexp_string_iterator_method, js_string_match_all, js_string_match_all_value, }; diff --git a/crates/perry-runtime/src/regex/match_all.rs b/crates/perry-runtime/src/regex/match_all.rs index d688418d54..fbe848e8c2 100644 --- a/crates/perry-runtime/src/regex/match_all.rs +++ b/crates/perry-runtime/src/regex/match_all.rs @@ -342,9 +342,36 @@ unsafe fn regexp_string_iter_result(value: JSValue, done: bool) -> f64 { pub unsafe fn dispatch_regexp_string_iterator_method( iter_obj: *mut ObjectHeader, method_name: &str, +) -> f64 { + dispatch_regexp_string_iterator_method_inner(iter_obj, method_name, true) +} + +/// Builtin advance only — the canonical prototype thunk's entry (#9019); see +/// `dispatch_array_iterator_method_builtin` for the recursion rationale. +pub(crate) unsafe fn dispatch_regexp_string_iterator_method_builtin( + iter_obj: *mut ObjectHeader, + method_name: &str, +) -> f64 { + dispatch_regexp_string_iterator_method_inner(iter_obj, method_name, false) +} + +unsafe fn dispatch_regexp_string_iterator_method_inner( + iter_obj: *mut ObjectHeader, + method_name: &str, + honor_override: bool, ) -> f64 { match method_name { "next" => { + // #9019: an own `next` assigned onto the iterator instance wins + // over the builtin advance, exactly as on the Map/Set path. + if honor_override { + if let Some(result) = crate::object::call_overridden_iterator_next( + iter_obj, + REGEXP_STRING_ITERATOR_CLASS_ID, + ) { + return result; + } + } let backing = f64::from_bits(crate::object::js_object_get_field(iter_obj, 0).bits()); let arr = js_nanbox_get_pointer(backing) as *const ArrayHeader; let idx = f64::from_bits(crate::object::js_object_get_field(iter_obj, 1).bits()) as u32; diff --git a/crates/perry-runtime/src/string/iter_object.rs b/crates/perry-runtime/src/string/iter_object.rs index 8916059879..6aa62ebcfa 100644 --- a/crates/perry-runtime/src/string/iter_object.rs +++ b/crates/perry-runtime/src/string/iter_object.rs @@ -83,16 +83,36 @@ use crate::iter_result::make_iter_result; pub unsafe fn dispatch_string_iterator_method( iter_obj: *mut ObjectHeader, method_name: &str, +) -> f64 { + dispatch_string_iterator_method_inner(iter_obj, method_name, true) +} + +/// Builtin advance only — the canonical prototype thunk's entry (#9019); +/// see `dispatch_array_iterator_method_builtin` for the recursion rationale. +pub(crate) unsafe fn dispatch_string_iterator_method_builtin( + iter_obj: *mut ObjectHeader, + method_name: &str, +) -> f64 { + dispatch_string_iterator_method_inner(iter_obj, method_name, false) +} + +unsafe fn dispatch_string_iterator_method_inner( + iter_obj: *mut ObjectHeader, + method_name: &str, + honor_override: bool, ) -> f64 { let scope = crate::gc::RuntimeHandleScope::new(); let iter_h = scope.root_nanbox_f64(js_nanbox_pointer(iter_obj as i64)); let iter_obj = || js_nanbox_get_pointer(iter_h.get_nanbox_f64()) as *mut ObjectHeader; match method_name { "next" => { - if let Some(result) = - crate::object::call_overridden_iterator_next(iter_obj(), STRING_ITERATOR_CLASS_ID) - { - return result; + if honor_override { + if let Some(result) = crate::object::call_overridden_iterator_next( + iter_obj(), + STRING_ITERATOR_CLASS_ID, + ) { + return result; + } } let backing = f64::from_bits(js_object_get_field(iter_obj(), 0).bits()); let arr_h = scope.root_nanbox_f64(backing); diff --git a/crates/perry-runtime/src/string/mod.rs b/crates/perry-runtime/src/string/mod.rs index 88e0318c72..907ce6d748 100644 --- a/crates/perry-runtime/src/string/mod.rs +++ b/crates/perry-runtime/src/string/mod.rs @@ -171,6 +171,7 @@ pub use html::{ }; pub use intern::{js_string_intern, scan_intern_table_roots, scan_intern_table_roots_mut}; pub use io::{js_string_error, js_string_print, js_string_warn}; +pub(crate) use iter_object::dispatch_string_iterator_method_builtin; pub use iter_object::{ dispatch_string_iterator_method, string_values_iter, STRING_ITERATOR_CLASS_ID, }; diff --git a/test-files/test_gap_iterator_patched_next.ts b/test-files/test_gap_iterator_patched_next.ts new file mode 100644 index 0000000000..f927c77d96 --- /dev/null +++ b/test-files/test_gap_iterator_patched_next.ts @@ -0,0 +1,117 @@ +// #9019: an own/patched `.next` on a builtin collection iterator must drive +// iteration (for-of, spread, manual calls), and a named property write on an +// iterator object must not corrupt its internal state. Pre-fix, the first +// named write landed at field index 0 — the backing-collection pointer — so +// `it.foo = 1` made iteration report done immediately and `it.next = fn` +// crashed the next builtin advance with SIGSEGV. + +// A: the issue reproducer — for-of over a Set iterator with a patched next. +{ + const s = new Set([10, 20, 30, 40]); + const it: any = s.values(); + let calls = 0; + const orig = it.next.bind(it); + it.next = function () { calls++; return orig(); }; + const got: number[] = []; + for (const v of it) got.push(v as number); + console.log("A", calls > 0, got.join(",")); +} + +// B: manual .next() drive of the same patch shape. +{ + const s = new Set([1, 2, 3]); + const it: any = s.values(); + let calls = 0; + const orig = it.next.bind(it); + it.next = function () { calls++; return orig(); }; + const got: number[] = []; + let r = it.next(); + while (!r.done) { got.push(r.value as number); r = it.next(); } + console.log("B", calls, got.join(",")); +} + +// C-F: a plain named write must not disturb iteration, per family. +{ + const m = new Map([["a", 1], ["b", 2]]); + const mi: any = m.entries(); + mi.foo = 123; + console.log("C", JSON.stringify(mi.next()), mi.foo); + const s = new Set([7]); + const si: any = s.values(); + si.foo = 9; + console.log("D", JSON.stringify(si.next())); + const ai: any = [5, 6].values(); + ai.foo = 9; + console.log("E", JSON.stringify(ai.next())); + const ti: any = "xy"[Symbol.iterator](); + ti.foo = 9; + console.log("F", JSON.stringify(ti.next())); +} + +// G: patched next on a Map iterator, driven by for-of, rewriting values. +{ + const m = new Map([["a", 1], ["b", 2]]); + const it: any = m.entries(); + const orig = it.next.bind(it); + it.next = function () { + const r = orig(); + if (!r.done) r.value = [r.value[0], (r.value[1] as number) * 10]; + return r; + }; + const got: string[] = []; + for (const [k, v] of it) got.push(k + "=" + v); + console.log("G", got.join(",")); +} + +// H: spread honors the patch too. +{ + const s = new Set([1, 2]); + const it: any = s.values(); + const orig = it.next.bind(it); + let calls = 0; + it.next = function () { calls++; return orig(); }; + console.log("H", [...it].join(","), calls > 0); +} + +// I: a non-callable own next throws TypeError when for-of drives it. +{ + const s = new Set([1]); + const it: any = s.values(); + it.next = 42; + try { + for (const v of it) console.log("I-unexpected", v); + console.log("I", "no-throw"); + } catch (e: any) { + console.log("I", e instanceof TypeError); + } +} + +// J: the added property is an ordinary own enumerable. +{ + const s = new Set([1]); + const it: any = s.values(); + it.foo = 5; + console.log("J", JSON.stringify(Object.keys(it)), JSON.stringify(it)); +} + +// K: deleting the patch restores the builtin advance. +{ + const s = new Set([1, 2]); + const it: any = s.values(); + it.next = function () { return { done: true, value: undefined }; }; + console.log("K1", it.next().done); + delete it.next; + const r = it.next(); + console.log("K2", r.value, r.done); +} + +// L: a patch that reaches the builtin through `this` and the prototype. +{ + const s = new Set([3, 4]); + const it: any = s.values(); + const proto = Object.getPrototypeOf(it); + it.next = function () { return proto.next.call(this); }; + const got: number[] = []; + for (const v of it) got.push(v as number); + console.log("L", got.join(",")); +} From 03f4ae86695e871725eaf811a913da6ecc218492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 29 Aug 2026 16:00:28 +0000 Subject: [PATCH 2/5] docs: changelog fragment for #9066 --- changelog.d/9066-iterator-reserved-floor.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/9066-iterator-reserved-floor.md diff --git a/changelog.d/9066-iterator-reserved-floor.md b/changelog.d/9066-iterator-reserved-floor.md new file mode 100644 index 0000000000..50d3ac6a92 --- /dev/null +++ b/changelog.d/9066-iterator-reserved-floor.md @@ -0,0 +1,7 @@ +### Fixed: named properties on builtin iterator objects corrupted their internal state (#9019, PR #9066) + +A by-name property write on a builtin collection-iterator object (Set/Map/array/string/buffer/regexp iterators, iterator helpers) derived the new key's field index from the object's empty keys array, so the first user property landed at field index 0 — the backing-collection pointer. `it.foo = 1` made the iterator report `done: true` on a live collection; `it.next = fn` made the next builtin advance dereference the closure as a `SetHeader` and SIGSEGV under `for…of` (issue #9019's reproducer, exit 139 on a clean build). + +The first by-name append to such a receiver now seeds its keys array with a reserved floor of leading tombstones (the #9038 hole marker every lookup/enumeration/delete path already skips), so user keys append past the raw internal fields; the hole-squeeze compaction preserves the reserved prefix (`crates/perry-runtime/src/object/reserved_floor.rs`). The class-id iterator dispatchers additionally honor an own `next` before the builtin advance (non-callable own values throw per IteratorNext), while the canonical prototype thunks keep running the builtin algorithm so a patch that delegates to its bound original cannot re-enter itself; the fused `for…of` arms validate the iterator result, and the stored-closure drain paths bind `this` to the iterator per Call(next, iterator). + +Validated byte-for-byte against the pinned Node 26.5.1 oracle across 13 cases in the new `test-files/test_gap_iterator_patched_next.ts`, plus 4 `reserved_floor` unit tests (including a field-0 fixture asserting the backing pointer is unchanged after the write). From d908219e5e2c533e4262a5c6a3d9d1547c8eedfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 29 Aug 2026 16:05:25 +0000 Subject: [PATCH 3/5] refactor(runtime): keep the reserved-floor seed out of the raw-handle ledger NaN-boxed handles in ensure_reserved_floor_keys and the existing refresh_roots_after_alloc macro (moved above the seed hook) in the by-name tail, so scripts/raw_handle_debt.py stays within its ceilings. --- .../src/object/field_set_by_name/tail.rs | 27 ++++++++---------- .../src/object/reserved_floor.rs | 28 +++++++++++-------- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/crates/perry-runtime/src/object/field_set_by_name/tail.rs b/crates/perry-runtime/src/object/field_set_by_name/tail.rs index b5feb8eedb..ceb8c72d15 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/tail.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/tail.rs @@ -445,6 +445,17 @@ pub(crate) fn set_field_by_name_object_tail( } } + // #7341: call after ANY allocating step, before the next use of + // obj/key/value. Rationale in changelog.d/7381-*, 7383-*. + macro_rules! refresh_roots_after_alloc { + () => {{ + obj = obj_handle.get_raw_mut_ptr::(); + key = key_handle.get_raw_const_ptr::(); + value = value_handle.get_nanbox_f64(); + interned_key = interned_key_handle.get_raw_const_ptr::(); + }}; + } + // #9019: a built-in iterator receiver (Set/Map/array/string/… // iterator object) keeps its internal state in RAW numbered fields // the keys array does not describe, so the append below would hand @@ -459,27 +470,13 @@ pub(crate) fn set_field_by_name_object_tail( && crate::object::reserved_slot_floor_for_class_id((*obj).class_id) != 0 && crate::object::ensure_reserved_floor_keys(obj) { - obj = obj_handle.get_raw_mut_ptr::(); - key = key_handle.get_raw_const_ptr::(); - value = value_handle.get_nanbox_f64(); - interned_key = interned_key_handle.get_raw_const_ptr::(); + refresh_roots_after_alloc!(); keys = crate::object::object_keys_array(obj); } let mut prev_keys_usize = keys as usize; let prev_shape_id = super::shapes::object_shape_stamp(obj); - // #7341: call after ANY allocating step, before the next use of - // obj/key/value. Rationale in changelog.d/7381-*, 7383-*. - macro_rules! refresh_roots_after_alloc { - () => {{ - obj = obj_handle.get_raw_mut_ptr::(); - key = key_handle.get_raw_const_ptr::(); - value = value_handle.get_nanbox_f64(); - interned_key = interned_key_handle.get_raw_const_ptr::(); - }}; - } - // FAST PATH: shape-transition cache with interned string pointer identity. // // #6084 item 6: the descriptor gate here used to be the process-global diff --git a/crates/perry-runtime/src/object/reserved_floor.rs b/crates/perry-runtime/src/object/reserved_floor.rs index 729ba5f605..ff05930dee 100644 --- a/crates/perry-runtime/src/object/reserved_floor.rs +++ b/crates/perry-runtime/src/object/reserved_floor.rs @@ -65,22 +65,28 @@ pub(crate) unsafe fn ensure_reserved_floor_keys(obj: *mut ObjectHeader) -> bool if floor == 0 || !super::object_keys_array(obj).is_null() { return false; } + // NaN-boxed handles rather than `root_raw_*_ptr`, so every reload is a + // `get_nanbox_f64` at the point of use and this module stays out of + // `scripts/raw_handle_debt.py`'s ledger (same idiom as + // `array/iterator.rs::js_iterator_to_array`). let scope = crate::gc::RuntimeHandleScope::new(); - let obj_h = scope.root_raw_mut_ptr(obj); + let obj_h = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(obj as i64)); let keys = crate::array::js_array_alloc(floor); if keys.is_null() { return false; } - let keys_h = scope.root_raw_mut_ptr(keys); - keys_h.with_mut_ptr::(|keys| { - for i in 0..floor as usize { - crate::array::store_array_slot(keys, i, crate::value::TAG_HOLE); - } - (*keys).length = floor; - crate::array::rebuild_array_layout_exact(keys); - }); - let obj = obj_h.get_raw_mut_ptr::(); - let keys = keys_h.get_raw_mut_ptr::(); + let keys_h = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(keys as i64)); + let keys = crate::value::js_nanbox_get_pointer(keys_h.get_nanbox_f64()) as *mut ArrayHeader; + for i in 0..floor as usize { + crate::array::store_array_slot(keys, i, crate::value::TAG_HOLE); + } + (*keys).length = floor; + crate::array::rebuild_array_layout_exact(keys); + // Reload both through their handles before publishing: nothing between + // the allocation and here allocates, but the publish path must never + // hold a pre-collection address. + let obj = crate::value::js_nanbox_get_pointer(obj_h.get_nanbox_f64()) as *mut ObjectHeader; + let keys = crate::value::js_nanbox_get_pointer(keys_h.get_nanbox_f64()) as *mut ArrayHeader; // The keys edge is changing: retire any typed layout trained on the old // (keys-less) representation before the successor is published, exactly // like `set_object_keys_array_with_live`. From ae2763b21e54b6a3fc955827988c3f5f6705ebe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 29 Aug 2026 16:11:19 +0000 Subject: [PATCH 4/5] fix(runtime): close the defineProperty and entry-lane append surfaces for reserved floors (#9019) ensure_key_in_keys_array (the accessor-define keys claim) seeds the reserved floor before its keys-null create arm, and the entry-lane transition cache declines reserved-layout class ids so an unseeded iterator can never receive a foreign sub-floor slot from an edge minted by another keyless family sharing its birth ShapeId. --- .../src/object/field_set_by_name.rs | 8 +++++ .../src/object/object_ops/keys_array.rs | 32 +++++++++++++------ 2 files changed, 31 insertions(+), 9 deletions(-) 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 fa6dcaec64..a1ba51181b 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -184,6 +184,14 @@ pub extern "C" fn js_object_set_field_by_name( if crate::object::object_is_regular(o) && class_id != 0 && class_id != NATIVE_MODULE_CLASS_ID + // #9019: reserved-layout iterator receivers must + // reach the tail so their floor seed runs before + // any transition is consulted — an unseeded one + // shares its keyless birth ShapeId with every + // other keyless object of the same live bound, so + // a cached edge here could hand it a foreign slot + // index below the floor. + && crate::object::reserved_slot_floor_for_class_id(class_id) == 0 && !super::prototype_chain::object_has_prototype_override(raw) && super::prop_plan::store_plan_check(class_id, key as usize) { diff --git a/crates/perry-runtime/src/object/object_ops/keys_array.rs b/crates/perry-runtime/src/object/object_ops/keys_array.rs index 0e31896678..391ae17303 100644 --- a/crates/perry-runtime/src/object/object_ops/keys_array.rs +++ b/crates/perry-runtime/src/object/object_ops/keys_array.rs @@ -25,18 +25,32 @@ pub(crate) unsafe fn ensure_key_in_keys_array( }}; } // If no keys array exists, create one with this key. - let keys = crate::object::object_keys_array(obj); + let mut keys = crate::object::object_keys_array(obj); if keys.is_null() { - let new_keys = crate::array::js_array_alloc(4); - refresh_define_property_roots!(); - let new_keys = crate::array::js_array_push(new_keys, JSValue::string_ptr(key as *mut _)); - refresh_define_property_roots!(); - set_object_keys_array(obj, new_keys); - if crate::object::object_live_slot_count(obj) == 0 { - set_object_live_slot_count(obj, 1); + // #9019: a reserved-layout iterator receiver seeds its floor of + // tombstones first, so a defineProperty key (this arm also serves + // accessor installs, which claim a keys slot with no data write) + // can never take a raw internal field's index. The seeded receiver + // then falls through to the ordinary existing-keys append below. + if crate::object::reserved_slot_floor_for_class_id((*obj).class_id) != 0 + && crate::object::ensure_reserved_floor_keys(obj) + { + refresh_define_property_roots!(); + keys = crate::object::object_keys_array(obj); + } else { + let new_keys = crate::array::js_array_alloc(4); + refresh_define_property_roots!(); + let new_keys = + crate::array::js_array_push(new_keys, JSValue::string_ptr(key as *mut _)); + refresh_define_property_roots!(); + set_object_keys_array(obj, new_keys); + if crate::object::object_live_slot_count(obj) == 0 { + set_object_live_slot_count(obj, 1); + } + return; } - return; } + let keys = keys; // Validate keys array pointer. The bare high-bits/low-address checks let // through values that are non-null and tag-free yet still not real heap // pointers (e.g. a stray `0x20_0000_0203` left in a miscompiled object's From d497fc154bc245311a5cd3c0ff27dd9f873787ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 29 Aug 2026 19:37:05 +0200 Subject: [PATCH 5/5] fix(runtime): restore the regex-engine cfg the new export took MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inserting `pub(crate) use match_all::dispatch_regexp_string_iterator_method_builtin` between the existing `#[cfg(feature = "regex-engine")]` and the `pub use` below it moved the attribute onto the NEW line, leaving the original export ungated. With the feature off, `perry-runtime` then names a module that does not exist: error[E0432]: unresolved import `match_all` It passes `cargo test -p perry-runtime --lib` (default features on) and fails `cargo check -p perry`, which is why it was invisible to the crate-level run. Same attribute-stealing shape as the doc comments repaired in #9013 and #9030 — an inserted line silently inherits the attribute or doc block above it. --- crates/perry-runtime/src/regex.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/perry-runtime/src/regex.rs b/crates/perry-runtime/src/regex.rs index 7f152a5ce7..26c693019a 100644 --- a/crates/perry-runtime/src/regex.rs +++ b/crates/perry-runtime/src/regex.rs @@ -66,6 +66,7 @@ use grammar::{ }; #[cfg(feature = "regex-engine")] pub(crate) use match_all::dispatch_regexp_string_iterator_method_builtin; +#[cfg(feature = "regex-engine")] pub use match_all::{ dispatch_regexp_string_iterator_method, js_string_match_all, js_string_match_all_value, };