From de5f5f1a42a5433ed63bb946c393d404b4eaad67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 31 Aug 2026 06:58:32 +0200 Subject: [PATCH 1/8] test(runtime): cover array prototype write and generic paths --- .../test_gap_9220_9221_array_proto_paths.ts | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 test-files/test_gap_9220_9221_array_proto_paths.ts diff --git a/test-files/test_gap_9220_9221_array_proto_paths.ts b/test-files/test_gap_9220_9221_array_proto_paths.ts new file mode 100644 index 0000000000..370c41286b --- /dev/null +++ b/test-files/test_gap_9220_9221_array_proto_paths.ts @@ -0,0 +1,205 @@ +// #9220 / #9221: the array-index write and borrowed Array.prototype method +// paths must use the same recorded-[[Prototype]] classification as direct +// indexed reads. These were silent wrong answers: assignments created an own +// element instead of invoking/rejecting through an inherited descriptor, and +// the generic array-like engine treated a prototype-filled hole as absent. +// +// The array-prototype cases are controls: both bugs pre-date support for a +// non-array custom prototype and reproduce when the prototype is itself an +// array, a shape Perry has long supported. + +const hasOwn = (value: any, key: PropertyKey) => + Object.prototype.hasOwnProperty.call(value, key); + +// --- #9220: inherited accessor writes ------------------------------------- + +const writeCalls: any[] = []; +const writeTarget: any = [1, 2, 3]; +let writeThis = false; +const writeProto: any = {}; +Object.defineProperty(writeProto, "9", { + configurable: true, + get() { + return "acc9"; + }, + set(this: any, value: any) { + writeCalls.push(value); + writeThis = this === writeTarget; + }, +}); +Object.setPrototypeOf(writeTarget, writeProto); +writeTarget[9] = 5; +console.log( + "write object accessor:", + writeCalls.join(","), + writeThis, + hasOwn(writeTarget, 9), + writeTarget[9], + writeTarget.length, +); + +// Exercise the in-bounds-hole shape as well as the out-of-bounds shape above. +const holeWriteCalls: any[] = []; +const holeWriteTarget: any = [0, , 2]; +const holeWriteProto: any = {}; +Object.defineProperty(holeWriteProto, "1", { + configurable: true, + get() { + return "acc1"; + }, + set(value: any) { + holeWriteCalls.push(value); + }, +}); +Object.setPrototypeOf(holeWriteTarget, holeWriteProto); +holeWriteTarget[1] = 7; +console.log( + "write hole accessor:", + holeWriteCalls.join(","), + hasOwn(holeWriteTarget, 1), + holeWriteTarget[1], + holeWriteTarget.length, +); + +// Control: the same inherited-setter bug with an Array as [[Prototype]]. +const arrayWriteCalls: any[] = []; +const arrayWriteTarget: any = [1]; +const arrayWriteProto: any = []; +Object.defineProperty(arrayWriteProto, "4", { + configurable: true, + get() { + return "arrayAcc4"; + }, + set(value: any) { + arrayWriteCalls.push(value); + }, +}); +Object.setPrototypeOf(arrayWriteTarget, arrayWriteProto); +arrayWriteTarget[4] = 11; +console.log( + "write array accessor:", + arrayWriteCalls.join(","), + hasOwn(arrayWriteTarget, 4), + arrayWriteTarget[4], + arrayWriteTarget.length, +); + +// An inherited writable data property does not intercept OrdinarySet: the +// receiver gets an own property and the prototype value is unchanged. +const dataProto: any = { 5: "protoFive" }; +const dataTarget: any = [1]; +Object.setPrototypeOf(dataTarget, dataProto); +dataTarget[5] = "ownFive"; +console.log( + "write inherited data:", + hasOwn(dataTarget, 5), + dataTarget[5], + dataProto[5], + dataTarget.length, +); + +// A non-writable inherited data property rejects the assignment. This file is +// an ES module (the repository package is type=module), so rejection throws. +const lockedProto: any = {}; +Object.defineProperty(lockedProto, "6", { + configurable: true, + enumerable: true, + value: "lockedSix", + writable: false, +}); +const lockedTarget: any = [1]; +Object.setPrototypeOf(lockedTarget, lockedProto); +let lockedThrew = false; +try { + lockedTarget[6] = "changed"; +} catch { + lockedThrew = true; +} +console.log( + "write inherited readonly:", + lockedThrew, + hasOwn(lockedTarget, 6), + lockedTarget[6], + lockedTarget.length, +); + +// --- #9221: borrowed Array.prototype methods over a real Array ------------ + +const genericProto: any = { 1: "holeFill" }; +const genericTarget: any = [0, , 2]; +Object.setPrototypeOf(genericTarget, genericProto); +const genericMapSeen: string[] = []; +const genericMapped: any = Array.prototype.map.call( + genericTarget, + (value: any, index: number) => { + genericMapSeen.push(index + ":" + value); + return String(value).toUpperCase(); + }, +); +const genericEachSeen: string[] = []; +Array.prototype.forEach.call(genericTarget, (value: any, index: number) => { + genericEachSeen.push(index + ":" + value); +}); +console.log("generic object join:", Array.prototype.join.call(genericTarget)); +console.log( + "generic object indexOf:", + Array.prototype.indexOf.call(genericTarget, "holeFill"), +); +console.log( + "generic object map:", + genericMapSeen.join("|"), + Array.prototype.join.call(genericMapped, "|"), + hasOwn(genericMapped, 1), +); +console.log("generic object forEach:", genericEachSeen.join("|")); + +// Accessor fill: Get must bind the original array as receiver. +let genericGetterCalls = 0; +let genericGetterThis = true; +const genericGetterTarget: any = [0, , 2]; +const genericGetterProto: any = {}; +Object.defineProperty(genericGetterProto, "1", { + configurable: true, + get(this: any) { + genericGetterCalls++; + genericGetterThis = genericGetterThis && this === genericGetterTarget; + return "getterFill"; + }, +}); +Object.setPrototypeOf(genericGetterTarget, genericGetterProto); +const getterJoined = Array.prototype.join.call(genericGetterTarget, "-"); +console.log( + "generic getter join:", + getterJoined, + genericGetterCalls, + genericGetterThis, +); + +// Control: identical generic-engine divergence with an Array [[Prototype]]. +const genericArrayProto: any = []; +genericArrayProto[1] = "arrayFill"; +const genericArrayTarget: any = [0, , 2]; +Object.setPrototypeOf(genericArrayTarget, genericArrayProto); +const genericArraySeen: string[] = []; +Array.prototype.forEach.call(genericArrayTarget, (value: any, index: number) => { + genericArraySeen.push(index + ":" + value); +}); +console.log( + "generic array control:", + Array.prototype.join.call(genericArrayTarget), + Array.prototype.indexOf.call(genericArrayTarget, "arrayFill"), + genericArraySeen.join("|"), +); + +// Default-chain control: ordinary holes remain holes for HasProperty methods. +const defaultTarget: any = [0, , 2]; +const defaultSeen: string[] = []; +Array.prototype.forEach.call(defaultTarget, (value: any, index: number) => { + defaultSeen.push(index + ":" + value); +}); +console.log( + "generic default control:", + Array.prototype.join.call(defaultTarget), + Array.prototype.indexOf.call(defaultTarget, undefined), + defaultSeen.join("|"), +); From fe73cdf280d641733abc9e3964723248dc16a52e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 31 Aug 2026 07:10:16 +0200 Subject: [PATCH 2/8] fix(runtime): honor inherited array indices across paths --- .../9220-array-prototype-index-paths.md | 14 ++++++ crates/perry-runtime/src/array/generic.rs | 29 ++++++++++- crates/perry-runtime/src/array/indexing.rs | 50 ++++++++++++++++--- crates/perry-runtime/src/array/mod.rs | 5 +- .../src/array/strict_store_tests.rs | 7 +++ 5 files changed, 96 insertions(+), 9 deletions(-) create mode 100644 changelog.d/9220-array-prototype-index-paths.md diff --git a/changelog.d/9220-array-prototype-index-paths.md b/changelog.d/9220-array-prototype-index-paths.md new file mode 100644 index 0000000000..8a47ba09ff --- /dev/null +++ b/changelog.d/9220-array-prototype-index-paths.md @@ -0,0 +1,14 @@ +### fix(runtime): honor inherited array indices in writes and borrowed methods + +Indexed assignment on an array with a recorded custom prototype now performs +the inherited descriptor walk before creating an own element. Prototype +setters therefore run with the array as their receiver, inherited non-writable +data properties reject the assignment, and inherited writable data properties +still allow the normal own-property creation. + +The generic array-like engine used by `Array.prototype..call(array)` +now uses the same recorded-prototype classification for `Get` and +`HasProperty`. Prototype-filled holes are consequently visible to `join`, +`indexOf`, `map`, `forEach`, and the other generic methods. Default-chain +arrays retain their existing fast paths, while Proxy prototypes keep their +dedicated trap handling. Fixes #9220 and #9221. diff --git a/crates/perry-runtime/src/array/generic.rs b/crates/perry-runtime/src/array/generic.rs index 0865d5ea47..5c3d47cc7b 100644 --- a/crates/perry-runtime/src/array/generic.rs +++ b/crates/perry-runtime/src/array/generic.rs @@ -430,6 +430,13 @@ pub(super) fn al_get(recv: f64, k: i64) -> f64 { if k < 0 { return undef(); } + // #9221: explicit Array.prototype..call(array, ...) must use + // the same recorded-prototype Get as a direct `array[k]`. Default-chain + // arrays retain the old `js_array_get_f64` lane, and Proxy prototypes + // remain on their dedicated path (the classification returns None). + if real_array_uses_recorded_spec_path(arr) { + return crate::array::array_spec_get(arr, k as u32); + } return js_array_get_f64(arr, k as u32); } let b = recv.to_bits(); @@ -535,6 +542,17 @@ fn object_get_property_chain(obj_ptr: usize, k: i64) -> f64 { undef() } +/// Whether a genuine Array receiver must use the #9219 recorded-prototype +/// classification for indexed Get/HasProperty. The process latch keeps the +/// per-array side-table probe out of programs that never retarget an array; +/// the classification itself excludes Proxy prototypes so their existing +/// dedicated trap path is not invoked twice. +#[inline] +fn real_array_uses_recorded_spec_path(arr: *const ArrayHeader) -> bool { + crate::object::prototype_chain::array_static_proto_recorded() + && unsafe { crate::array::array_custom_prototype(arr).is_some() } +} + /// `HasProperty(ToObject(recv), k)`. pub(super) fn al_has(recv: f64, k: i64) -> bool { if k < 0 { @@ -548,8 +566,17 @@ pub(super) fn al_has(recv: f64, k: i64) -> bool { } let el = *((arr as *const u8).add(std::mem::size_of::()) as *const f64) .add(k as usize); - return el.to_bits() != TAG_HOLE; + if el.to_bits() != TAG_HOLE { + return true; + } } + // #9221: a hole is absent only from the receiver. On a retargeted + // array, HasProperty must walk the recorded chain; this is exactly the + // `ArrayCustomProto::{Null, Array, Other}` policy used by direct reads. + if real_array_uses_recorded_spec_path(arr) { + return crate::array::array_spec_has_index(arr, k as u32); + } + return false; } let b = recv.to_bits(); if is_string_value(b) { diff --git a/crates/perry-runtime/src/array/indexing.rs b/crates/perry-runtime/src/array/indexing.rs index 2e6a8590e1..ba893c762c 100644 --- a/crates/perry-runtime/src/array/indexing.rs +++ b/crates/perry-runtime/src/array/indexing.rs @@ -535,10 +535,11 @@ pub(crate) fn array_spec_set(arr: *mut ArrayHeader, index: u32, value: f64) -> * unsafe { if array_has_own_index(arr_handle.get_raw_mut_ptr::(), index) { - return js_array_set_f64_extend_strict( + return js_array_set_f64_extend_strict_impl( arr_handle.get_raw_mut_ptr::(), index, value_handle.get_nanbox_f64(), + true, ); } @@ -598,10 +599,11 @@ pub(crate) fn array_spec_set(arr: *mut ArrayHeader, index: u32, value: f64) -> * } } - js_array_set_f64_extend_strict( + js_array_set_f64_extend_strict_impl( arr_handle.get_raw_mut_ptr::(), index, value_handle.get_nanbox_f64(), + true, ) } } @@ -1502,12 +1504,20 @@ pub(crate) unsafe fn try_strict_dense_number_store( } else { value_bits }; + let slot = super::header::array_elements_ptr(arr).add(index as usize); + // #9220: this lane used to fill a hole directly. A hole is not an own + // property, so an inherited setter / non-writable data descriptor must be + // consulted before an own element can be created. A raw-f64 DENSE layout + // proves there are no holes and keeps its bit-for-bit old hot path; every + // other admitted layout proves ownership with the slot Perry is about to + // overwrite. + if flags & crate::gc::GC_ARRAY_RAW_F64_LAYOUT == 0 && ptr::read(slot) == crate::value::TAG_HOLE + { + return None; + } // GC_STORE_AUDIT(POINTER_FREE): a number never holds a heap pointer, and // the receiver's layout was proved pointer-free or tag-scanned above. - ptr::write( - super::header::array_elements_ptr(arr).add(index as usize), - store_bits, - ); + ptr::write(slot, store_bits); Some(arr) } @@ -1614,6 +1624,20 @@ pub extern "C" fn js_array_set_f64_extend_strict( arr: *mut ArrayHeader, index: u32, value: f64, +) -> *mut ArrayHeader { + js_array_set_f64_extend_strict_impl(arr, index, value, false) +} + +/// Strict indexed assignment after optionally completing the inherited +/// descriptor walk. `prototype_already_checked` is true only for the callback +/// from [`array_spec_set`]; it prevents a writable inherited data property +/// from recursing when the spec walk proceeds to create the receiver's own +/// element. +fn js_array_set_f64_extend_strict_impl( + arr: *mut ArrayHeader, + index: u32, + value: f64, + prototype_already_checked: bool, ) -> *mut ArrayHeader { // Two exact fast lanes, each storing only what the general path below // would store and declining every shape it cannot prove. The plain-number @@ -1644,6 +1668,20 @@ pub extern "C" fn js_array_set_f64_extend_strict( return js_array_set_f64_extend(arr, index, value); } + // #9220: only a retargeted array with no own index pays the inherited + // [[Set]] walk. `array_custom_prototype` is the #9219 classification shared + // with reads/HasProperty and deliberately returns None for a Proxy + // prototype, whose dedicated dispatch must remain single-shot. Existing + // own elements have already had every applicable dense lane above; the + // fallback still needs the ownership check for descriptor/restricted + // shapes that correctly declined those lanes. + if !prototype_already_checked + && unsafe { array_custom_prototype(clean).is_some() } + && unsafe { !array_has_own_index(clean, index) } + { + return array_spec_set(clean, index, value); + } + // SAFETY: the clean above resolved this exact live plain-array head. The // guard performs no Perry allocation/safepoint, so the proof remains live // for the store core. diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index af030d5535..74285f8582 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -138,8 +138,9 @@ pub use self::immutable::{ js_array_with, js_arraylike_copy_within, }; pub(crate) use self::indexing::{ - array_has_own_index, array_iteration_is_exotic, array_iteration_is_exotic_resolved, - array_prototype_has_index_flag, array_spec_get, array_spec_has_index, array_spec_set, + array_custom_prototype, array_has_own_index, array_iteration_is_exotic, + array_iteration_is_exotic_resolved, array_prototype_has_index_flag, array_spec_get, + array_spec_has_index, array_spec_set, }; pub use self::indexing::{ js_array_get_element, js_array_get_element_f64, js_array_get_f64, js_array_get_f64_unchecked, diff --git a/crates/perry-runtime/src/array/strict_store_tests.rs b/crates/perry-runtime/src/array/strict_store_tests.rs index 3b3a0efca6..41f4a34f21 100644 --- a/crates/perry-runtime/src/array/strict_store_tests.rs +++ b/crates/perry-runtime/src/array/strict_store_tests.rs @@ -127,5 +127,12 @@ fn strict_dense_number_store_fast_lane_matches_the_general_path() { let out = js_array_set_f64_extend_strict(boxed, 3, 3.0); assert_eq!((*out).length, 4); assert_eq!(js_array_get_f64(out, 3), 3.0); + + // #9220: an in-bounds hole is not an own property. The number lane + // must decline it so the strict entry can consult an inherited index + // setter / non-writable data descriptor before creating an element. + js_array_set_length(out, 5.0); + assert!(!lane(out, 4, 8.0), "hole slot requires the [[Set]] walk"); + assert!(!array_has_own_index(out, 4)); } } From e629f02e944087ac09d3d530ba11802e702067cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 31 Aug 2026 07:40:18 +0200 Subject: [PATCH 3/8] fix(runtime): preserve strict semantics in array store fallback --- crates/perry-runtime/src/array/indexing.rs | 5 +++-- crates/perry-runtime/src/typed_feedback.rs | 9 ++++++++- .../perry-runtime/src/typed_feedback/tests.rs | 19 +++++++++++++++++-- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/crates/perry-runtime/src/array/indexing.rs b/crates/perry-runtime/src/array/indexing.rs index ba893c762c..21382b0a2c 100644 --- a/crates/perry-runtime/src/array/indexing.rs +++ b/crates/perry-runtime/src/array/indexing.rs @@ -1511,8 +1511,9 @@ pub(crate) unsafe fn try_strict_dense_number_store( // proves there are no holes and keeps its bit-for-bit old hot path; every // other admitted layout proves ownership with the slot Perry is about to // overwrite. - if flags & crate::gc::GC_ARRAY_RAW_F64_LAYOUT == 0 && ptr::read(slot) == crate::value::TAG_HOLE - { + let may_have_holes = flags & crate::gc::GC_ARRAY_RAW_F64_LAYOUT == 0 + || flags & crate::gc::GC_ARRAY_RAW_F64_HOLES != 0; + if may_have_holes && ptr::read(slot) == crate::value::TAG_HOLE { return None; } // GC_STORE_AUDIT(POINTER_FREE): a number never holds a heap pointer, and diff --git a/crates/perry-runtime/src/typed_feedback.rs b/crates/perry-runtime/src/typed_feedback.rs index b5a649c5f2..97e74a4c09 100644 --- a/crates/perry-runtime/src/typed_feedback.rs +++ b/crates/perry-runtime/src/typed_feedback.rs @@ -2598,7 +2598,14 @@ pub extern "C" fn js_typed_feedback_array_index_set_fallback_boxed( (raw_addr as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; match (*gc_header).obj_type { crate::gc::GC_TYPE_ARRAY | crate::gc::GC_TYPE_LAZY_ARRAY => { - let new_arr = crate::array::js_array_set_index_or_string( + // #9220: this is the cold continuation of a source-level + // `arr[index] = value` after the inline guard rejects a + // retargeted/prototype-sensitive array. It must preserve the + // assignment's strict Set semantics; the non-strict helper + // bypassed `js_array_set_f64_extend_strict` entirely, so an + // inherited setter/non-writable index was silently replaced + // by a new own element. + let new_arr = crate::array::js_array_set_index_or_string_strict( raw_addr as *mut ArrayHeader, index, value, diff --git a/crates/perry-runtime/src/typed_feedback/tests.rs b/crates/perry-runtime/src/typed_feedback/tests.rs index aa02479a7d..58cabd8bab 100644 --- a/crates/perry-runtime/src/typed_feedback/tests.rs +++ b/crates/perry-runtime/src/typed_feedback/tests.rs @@ -59,6 +59,20 @@ fn assert_undefined(value: f64) { assert_eq!(value.to_bits(), crate::value::TAG_UNDEFINED); } +fn catch_runtime_throw(f: impl FnOnce()) -> bool { + let env = crate::exception::js_try_push(); + let jumped = unsafe { crate::ffi::setjmp::setjmp(env as *mut std::os::raw::c_int) }; + if jumped == 0 { + f(); + crate::exception::js_try_end(); + false + } else { + crate::exception::js_try_end(); + crate::exception::js_clear_exception(); + true + } +} + fn class_instance( class_id: u32, key_name: &'static [u8], @@ -572,8 +586,9 @@ fn typed_feedback_array_set_guards_reject_frozen_arrays() { 0 ); - let returned = js_typed_feedback_array_index_set_fallback_boxed(70, arr_box, 0.0, 99.0); - assert_eq!(returned.to_bits(), arr_box.to_bits()); + assert!(catch_runtime_throw(|| { + js_typed_feedback_array_index_set_fallback_boxed(70, arr_box, 0.0, 99.0); + })); assert_eq!( crate::array::js_array_get_f64(arr, 0).to_bits(), 1.0f64.to_bits() From ee294410ae6014d9704afdeacdf06ef3d25dba85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 31 Aug 2026 15:44:45 +0200 Subject: [PATCH 4/8] fix(runtime): the [[Set]] owner walk takes the synthetic-class prototype hop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Object.create(p)` records no observable `[[Prototype]]` for the result — it models the link with a synthetic class id (#809). `array_object_proto_index_owner` hopped only through the recorded-prototype table, so an inherited index accessor or non-writable index living TWO links up was missed and `arr[i] = v` created an own element anyway, even though the matching READ already resolved it. Take the same hop the read walk takes so [[Get]] and [[Set]] agree on the chain. Fixture: 'write deep create', 'write deep setproto', 'write frozen receiver' and the 'write frozen default' control. --- crates/perry-runtime/src/array/indexing.rs | 22 ++++- .../test_gap_9220_9221_array_proto_paths.ts | 91 +++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/array/indexing.rs b/crates/perry-runtime/src/array/indexing.rs index 21382b0a2c..4650249ab2 100644 --- a/crates/perry-runtime/src/array/indexing.rs +++ b/crates/perry-runtime/src/array/indexing.rs @@ -444,7 +444,27 @@ unsafe fn array_object_proto_index_owner(proto_bits: u64, key: &str) -> usize { } match crate::object::prototype_chain::object_static_prototype(addr) { Some(next) => bits = next, - None => return 0, + // #9220: `Object.create(p)` does NOT record `p` in the observable + // prototype side table — `js_object_create` models the link with a + // SYNTHETIC CLASS ID whose `class_prototype_object` entry is `p` + // (#809). The recorded-prototype hop alone therefore stops one link + // short, and an inherited accessor / non-writable index that the + // READ side already resolves (`js_object_get_field_by_name`'s + // `class_id != 0` branch, reached through + // `resolve_inherited_field_from_prototype`) was silently replaced by + // a new own element on the array. Take the same hop the read walk + // takes so `[[Set]]` and `[[Get]]` agree on the chain. + None => { + let class_id = (*(addr as *const crate::ObjectHeader)).class_id; + if class_id == 0 { + return 0; + } + let synth = crate::object::class_prototype_object(class_id); + if synth.is_null() || synth as usize == addr { + return 0; + } + bits = crate::value::js_nanbox_pointer(synth as i64).to_bits(); + } } } 0 diff --git a/test-files/test_gap_9220_9221_array_proto_paths.ts b/test-files/test_gap_9220_9221_array_proto_paths.ts index 370c41286b..1934ad120f 100644 --- a/test-files/test_gap_9220_9221_array_proto_paths.ts +++ b/test-files/test_gap_9220_9221_array_proto_paths.ts @@ -123,6 +123,97 @@ console.log( lockedTarget.length, ); +// The inherited descriptor may be further up the chain. `Object.create(p)` +// models the link with a synthetic class id rather than a recorded prototype, +// so the [[Set]] owner walk has to take the same hop the [[Get]] walk takes. +const deepCalls: any[] = []; +const deepGrand: any = {}; +Object.defineProperty(deepGrand, "4", { + configurable: true, + get() { + return "deep4"; + }, + set(value: any) { + deepCalls.push(value); + }, +}); +const deepTarget: any = [1]; +Object.setPrototypeOf(deepTarget, Object.create(deepGrand)); +deepTarget[4] = 3; +console.log( + "write deep create:", + deepCalls.join(","), + hasOwn(deepTarget, 4), + deepTarget[4], + deepTarget.length, +); + +// The same shape with every link installed by setPrototypeOf. +const deepSetCalls: any[] = []; +const deepSetGrand: any = {}; +Object.defineProperty(deepSetGrand, "4", { + configurable: true, + get() { + return "deepSet4"; + }, + set(value: any) { + deepSetCalls.push(value); + }, +}); +const deepSetMid: any = {}; +Object.setPrototypeOf(deepSetMid, deepSetGrand); +const deepSetTarget: any = [1]; +Object.setPrototypeOf(deepSetTarget, deepSetMid); +deepSetTarget[4] = 6; +console.log( + "write deep setproto:", + deepSetCalls.join(","), + hasOwn(deepSetTarget, 4), + deepSetTarget[4], + deepSetTarget.length, +); + +// A non-extensible or frozen receiver does not stop an inherited setter: +// OrdinarySet finds the prototype accessor before it ever reaches the +// receiver's own-property create. +const frozenCalls: any[] = []; +const frozenProto: any = {}; +Object.defineProperty(frozenProto, "4", { + configurable: true, + get() { + return "frozen4"; + }, + set(value: any) { + frozenCalls.push(value); + }, +}); +const frozenTarget: any = [1]; +Object.setPrototypeOf(frozenTarget, frozenProto); +Object.freeze(frozenTarget); +frozenTarget[4] = 8; +console.log( + "write frozen receiver:", + frozenCalls.join(","), + hasOwn(frozenTarget, 4), + frozenTarget.length, +); + +// Control: the default chain keeps its strict rejection on a frozen array. +const frozenDefault: any = [1, 2, 3]; +Object.freeze(frozenDefault); +let frozenDefaultThrew = false; +try { + frozenDefault[0] = 9; +} catch { + frozenDefaultThrew = true; +} +console.log( + "write frozen default:", + frozenDefaultThrew, + frozenDefault[0], + frozenDefault.length, +); + // --- #9221: borrowed Array.prototype methods over a real Array ------------ const genericProto: any = { 1: "holeFill" }; From c6edaf91066a14b3073997520e953e5ab372fbfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 31 Aug 2026 15:46:33 +0200 Subject: [PATCH 5/8] perf(runtime): gate the strict lane's hole decline on the array-prototype latch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #9220's hole decline in `try_strict_dense_number_store` was unconditional for every layout that can hold a hole, so a default-chain `new Array(n)` fill — holey by construction — lost the fast lane for a walk that can never find anything. Lead with the existing process latch: with no array ever retargeted the lane is bit-for-bit what it was, and the slot is not even read. The unit test now A/Bs the two arms on two identical in-bounds holes of one array, with the latch as the only variable. --- crates/perry-runtime/src/array/indexing.rs | 10 +++++++- .../src/array/strict_store_tests.rs | 23 ++++++++++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/crates/perry-runtime/src/array/indexing.rs b/crates/perry-runtime/src/array/indexing.rs index 4650249ab2..a824792b39 100644 --- a/crates/perry-runtime/src/array/indexing.rs +++ b/crates/perry-runtime/src/array/indexing.rs @@ -1531,9 +1531,17 @@ pub(crate) unsafe fn try_strict_dense_number_store( // proves there are no holes and keeps its bit-for-bit old hot path; every // other admitted layout proves ownership with the slot Perry is about to // overwrite. + // The process latch leads: an array can only have an inherited index when + // SOME array has been retargeted, so a program that never calls + // `Object.setPrototypeOf` on an array keeps this lane bit-for-bit as it was + // (one relaxed load of a static bool, and the slot is never read here). + // `new Array(n)` fills are holey and would otherwise all fall off the lane. let may_have_holes = flags & crate::gc::GC_ARRAY_RAW_F64_LAYOUT == 0 || flags & crate::gc::GC_ARRAY_RAW_F64_HOLES != 0; - if may_have_holes && ptr::read(slot) == crate::value::TAG_HOLE { + if crate::object::prototype_chain::array_static_proto_recorded() + && may_have_holes + && ptr::read(slot) == crate::value::TAG_HOLE + { return None; } // GC_STORE_AUDIT(POINTER_FREE): a number never holds a heap pointer, and diff --git a/crates/perry-runtime/src/array/strict_store_tests.rs b/crates/perry-runtime/src/array/strict_store_tests.rs index 41f4a34f21..3f3f3c092a 100644 --- a/crates/perry-runtime/src/array/strict_store_tests.rs +++ b/crates/perry-runtime/src/array/strict_store_tests.rs @@ -130,9 +130,26 @@ fn strict_dense_number_store_fast_lane_matches_the_general_path() { // #9220: an in-bounds hole is not an own property. The number lane // must decline it so the strict entry can consult an inherited index - // setter / non-writable data descriptor before creating an element. - js_array_set_length(out, 5.0); - assert!(!lane(out, 4, 8.0), "hole slot requires the [[Set]] walk"); + // setter / non-writable data descriptor before creating an element — + // but ONLY once some array has been retargeted. With the process latch + // clear (the overwhelmingly common case, including every `new Array(n)` + // fill) the lane keeps filling holes exactly as it did before #9220. + // + // Indices 4 and 5 are the SAME shape — two in-bounds holes on one + // array — so the latch is the only variable between the two arms. + js_array_set_length(out, 6.0); assert!(!array_has_own_index(out, 4)); + assert!(!array_has_own_index(out, 5)); + let latch_was = + crate::object::prototype_chain::test_swap_array_static_proto_recorded(false); + assert!( + lane(out, 4, 8.0), + "no recorded array prototype: the hole fill stays on the fast lane" + ); + assert!(array_has_own_index(out, 4)); + crate::object::prototype_chain::test_swap_array_static_proto_recorded(true); + assert!(!lane(out, 5, 8.0), "hole slot requires the [[Set]] walk"); + assert!(!array_has_own_index(out, 5)); + crate::object::prototype_chain::test_swap_array_static_proto_recorded(latch_was); } } From 7faf69bd0dabfe323c558c1c49679db4c5512675 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 31 Aug 2026 16:05:28 +0200 Subject: [PATCH 6/8] perf(runtime): the [[Set]] walk's guard leads with the array-prototype latch Same reasoning as the read/HasProperty twin in `generic.rs`: recording a prototype on any array sets the process latch, so a clear latch proves this receiver cannot have one and the per-array side-table probe is skipped. The cold store path (extension past length, holey pointer fills, descriptor and restricted shapes) no longer pays a registry probe in programs that never call `Object.setPrototypeOf` on an array. --- crates/perry-runtime/src/array/indexing.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/perry-runtime/src/array/indexing.rs b/crates/perry-runtime/src/array/indexing.rs index a824792b39..466af66a0d 100644 --- a/crates/perry-runtime/src/array/indexing.rs +++ b/crates/perry-runtime/src/array/indexing.rs @@ -1704,7 +1704,12 @@ fn js_array_set_f64_extend_strict_impl( // own elements have already had every applicable dense lane above; the // fallback still needs the ownership check for descriptor/restricted // shapes that correctly declined those lanes. + // The process latch leads for the same reason it does in the read/HasProperty + // twin (`generic::real_array_uses_recorded_spec_path`): recording a + // prototype on ANY array sets it, so a clear latch proves this array cannot + // have one and the side-table probe is skipped entirely. if !prototype_already_checked + && crate::object::prototype_chain::array_static_proto_recorded() && unsafe { array_custom_prototype(clean).is_some() } && unsafe { !array_has_own_index(clean, index) } { From 1e635f4696d9079212339c4bd73836e1f4d243f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 31 Aug 2026 16:28:23 +0200 Subject: [PATCH 7/8] docs(changelog): record the chain hop and the latch gating --- changelog.d/9220-array-prototype-index-paths.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/changelog.d/9220-array-prototype-index-paths.md b/changelog.d/9220-array-prototype-index-paths.md index 8a47ba09ff..9021e363c6 100644 --- a/changelog.d/9220-array-prototype-index-paths.md +++ b/changelog.d/9220-array-prototype-index-paths.md @@ -12,3 +12,14 @@ now uses the same recorded-prototype classification for `Get` and `indexOf`, `map`, `forEach`, and the other generic methods. Default-chain arrays retain their existing fast paths, while Proxy prototypes keep their dedicated trap handling. Fixes #9220 and #9221. + +The `[[Set]]` owner walk takes the same chain hops the `[[Get]]` walk takes: +`Object.create(p)` models its link with a synthetic class id rather than a +recorded prototype (#809), so without that hop an inherited accessor two links +up was still silently replaced by an own element. + +Every one of the three new gates leads with the existing `array_static_proto_recorded` +process latch, so a program that never retargets an array keeps the previous +code path exactly — the strict store's number lane does not even read the slot +it is about to write, and the cold store tail performs no prototype-registry +probe. From c2bb79515e5d231d196f8de9e7e1dd48ed4165d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 31 Aug 2026 16:35:28 +0200 Subject: [PATCH 8/8] refactor(runtime): split the spec prototype-chain block out of indexing.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's 2000-line file cap (scripts/check_file_size.sh, test.yml) rejected this branch: indexing.rs sat 2 lines under the cap on main and the #9220/#9221 work pushed it to 2070. Move the spec-level indexed [[Get]] / [[HasProperty]] / [[Set]] block — the ArrayCustomProto classification, the object-prototype walks, array_spec_{get,has_index,set} and array_oob_prototype_get — into `indexing_proto_chain.rs`, declared as a CHILD of `indexing` (the same #[path] pattern indexing_keyed.rs uses) so every parent-private helper stays reachable through `super::*` and no visibility widens except the one pub(super) the parent needs back. Pure move: the comment-stripped line multiset is identical apart from the module wiring. The addr-class ratchet baseline is redistributed, not raised — 521 sites before and after — and the new file inherits indexing.rs's existing GcHeader-probe allowlist entry, exactly as indexing_keyed.rs did at the last split. indexing.rs 2070 -> 1603; check_file_size, addr_class_inventory, shape_descriptor_census, check_thread_locals and check_test_registration all pass. --- crates/perry-runtime/src/array/indexing.rs | 480 +---------------- .../src/array/indexing_proto_chain.rs | 485 ++++++++++++++++++ scripts/addr_class_allowlist.txt | 1 + scripts/addr_class_ratchet_baseline.txt | 3 +- 4 files changed, 494 insertions(+), 475 deletions(-) create mode 100644 crates/perry-runtime/src/array/indexing_proto_chain.rs diff --git a/crates/perry-runtime/src/array/indexing.rs b/crates/perry-runtime/src/array/indexing.rs index 466af66a0d..0008ba3d2a 100644 --- a/crates/perry-runtime/src/array/indexing.rs +++ b/crates/perry-runtime/src/array/indexing.rs @@ -6,10 +6,16 @@ use std::sync::atomic::Ordering; #[path = "indexing_keyed.rs"] mod keyed; +#[path = "indexing_proto_chain.rs"] +mod proto_chain; pub use keyed::{ js_array_get_index_or_string, js_array_set_index_or_string, js_array_set_index_or_string_strict, js_array_set_string_key, }; +use proto_chain::array_oob_prototype_get; +pub(crate) use proto_chain::{ + array_custom_prototype, array_spec_get, array_spec_has_index, array_spec_set, +}; const MAX_DENSE_ARRAY_GROW_LENGTH: u32 = 1_000_000; @@ -79,58 +85,6 @@ pub(crate) fn note_array_index_write(arr: usize) { } } -/// Out-of-bounds element read fallback: `Array.prototype[index]` when the -/// prototype has indexed properties (see `ARRAY_PROTO_HAS_INDEX`). Returns the -/// inherited value, or `undefined` if absent. Skipped entirely when the -/// receiver IS `Array.prototype` (avoids self-recursion) or the flag is unset. -/// -/// #6981: the `proto != receiver` self-recursion guard is an OBJECT IDENTITY -/// test, so both sides must be forwarding-resolved. `js_array_get_f64` resolves -/// its receiver through `clean_arr_ptr`; the prototype address comes from a -/// memoized cache, so it is healed here too. Comparing a stale address against -/// a resolved one makes the guard silently stop firing and -/// `js_array_get_f64` ⇄ this function recurse without bound. -#[inline] -unsafe fn array_oob_prototype_get(receiver: usize, index: u32) -> f64 { - const TAG_UNDEFINED_F64: f64 = f64::from_bits(0x7FFC_0000_0000_0001u64); - // A custom [[Prototype]] (`Object.setPrototypeOf(arr, p)`) replaces the - // default chain — gated on a global relaxed flag. #9192: `p` need not be an - // array; a plain object / `Object.create(Array.prototype)` result answers - // the whole lookup through the generic resolver. - if crate::object::prototype_chain::array_static_proto_recorded() { - let arr = receiver as *const ArrayHeader; - match array_custom_prototype(arr) { - Some(ArrayCustomProto::Null) => return TAG_UNDEFINED_F64, - Some(ArrayCustomProto::Other(bits)) => { - return array_object_proto_index_get(arr, bits, index).unwrap_or(TAG_UNDEFINED_F64) - } - Some(ArrayCustomProto::Array(proto_arr)) => { - if index < (*proto_arr).length && array_has_own_index(proto_arr, index) { - return js_array_get_f64(proto_arr, index); - } - } - None => {} - } - } - if ARRAY_PROTO_HAS_INDEX.load(Ordering::Relaxed) { - let proto = array_prototype_addr(); - if proto != 0 && proto != crate::value::resolve_forwarding(receiver) { - let proto_arr = proto as *const ArrayHeader; - if index < (*proto_arr).length && array_has_own_index(proto_arr, index) { - return js_array_get_f64(proto_arr, index); - } - } - } - // Object.prototype indexed property (data or defineProperty accessor): - // arr → Array.prototype → Object.prototype (concat/S15.4.4.4_A3_T3). - if OBJECT_PROTO_HAS_INDEX.load(Ordering::Relaxed) - && crate::array::object_prototype_has_index_prop(index) - { - return crate::array::sort_object_prototype_index_get(index); - } - TAG_UNDEFINED_F64 -} - #[inline] unsafe fn array_sparse_index_property_get(arr: *const ArrayHeader, index: u32) -> Option { let arr = clean_arr_ptr(arr); @@ -228,428 +182,6 @@ pub(crate) unsafe fn array_has_own_index(arr: *const ArrayHeader, index: u32) -> false } -/// Spec `[[HasProperty]]`(O, ToString(index)) for an ordinary Array receiver: -/// own property OR inherited indexed property from `Array.prototype`. -pub(crate) fn array_spec_has_index(arr: *const ArrayHeader, index: u32) -> bool { - let arr = clean_arr_ptr(arr); - if arr.is_null() { - return false; - } - unsafe { - if array_has_own_index(arr, index) { - return true; - } - // An explicit `Object.setPrototypeOf(arr, p)` REPLACES the default - // chain. A real-array `p` keeps the original lane (its own indices - // first, then the implicit `Array.prototype` tail below — test262 - // copyWithin/coerced-values-start-change-*). #9192: any other `p` - // answers the whole question by itself, so the default-chain tail must - // not run after it. - match array_custom_prototype(arr) { - Some(ArrayCustomProto::Null) => return false, - Some(ArrayCustomProto::Other(bits)) => { - return array_object_proto_index_has(bits, index) - } - Some(ArrayCustomProto::Array(proto_arr)) => { - if index < (*proto_arr).length && array_has_own_index(proto_arr, index) { - return true; - } - } - None => {} - } - if ARRAY_PROTO_HAS_INDEX.load(Ordering::Relaxed) { - let proto = array_prototype_addr(); - if proto != 0 && proto != arr as usize { - let proto_arr = proto as *const ArrayHeader; - if index < (*proto_arr).length && array_has_own_index(proto_arr, index) { - return true; - } - } - } - if OBJECT_PROTO_HAS_INDEX.load(Ordering::Relaxed) - && crate::array::object_prototype_has_index_prop(index) - { - return true; - } - false - } -} - -/// How a recorded custom `[[Prototype]]` on a real array must be consulted. -/// -/// #9192: before this classification the array index paths accepted a recorded -/// prototype ONLY when it was itself a `GC_TYPE_ARRAY`; every other shape (a -/// plain object, an `Object.create(Array.prototype)` result, a class prototype) -/// was recorded — latching the process-wide index deopt — and then silently -/// ignored, so the array inherited nothing at all. -pub(crate) enum ArrayCustomProto { - /// `Object.setPrototypeOf(arr, null)`: nothing is inherited, and the - /// implicit `Array.prototype` → `Object.prototype` chain is gone too. - Null, - /// The recorded prototype is itself a real array — the original lane, kept - /// bit-for-bit (test262 copyWithin/coerced-values-start-change-*). - Array(*const ArrayHeader), - /// Any other object: resolved through the generic object machinery with the - /// array as the receiver, so prototype accessors see the right `this` and - /// further hops (`Object.create(Array.prototype)`, proxies) are walked. - Other(u64), -} - -/// Classify the `[[Prototype]]` an explicit `Object.setPrototypeOf` / -/// `__proto__` / `Reflect.setPrototypeOf` recorded for `arr`. `None` when the -/// array still carries the default `Array.prototype` chain. -pub(crate) unsafe fn array_custom_prototype(arr: *const ArrayHeader) -> Option { - let bits = crate::object::prototype_chain::object_static_prototype(arr as usize)?; - if bits == crate::value::TAG_NULL { - return Some(ArrayCustomProto::Null); - } - if let Some(proto_arr) = array_custom_array_prototype_from_bits(arr, bits) { - return Some(ArrayCustomProto::Array(proto_arr)); - } - // A Proxy prototype keeps its existing dedicated handling in the `in` / - // property-get arms; routing it through the generic resolver here as well - // would invoke the `has` trap twice, which is observable. - if crate::proxy::js_proxy_is_proxy(f64::from_bits(bits)) != 0 { - return None; - } - // A pointer-shaped record that is not a real array is the #9192 case. A - // record that is not pointer-shaped at all (a stale/garbage entry) is - // reported as "no custom prototype", exactly as before. - pointer_bits_of_recorded_prototype(bits).map(|_| ArrayCustomProto::Other(bits)) -} - -/// The heap address a recorded prototype's bits name, if they are pointer -/// shaped at all. The record may be NaN-boxed (0x7FFD) or a RAW untagged -/// pointer (module-level arrays are stored as raw I64s). -fn pointer_bits_of_recorded_prototype(bits: u64) -> Option { - let raw = if (bits >> 48) == 0x7FFD { - (bits & crate::value::POINTER_MASK) as usize - } else if (bits >> 48) == 0 && bits > 0x10000 { - bits as usize - } else { - return None; - }; - if raw == 0 { - None - } else { - Some(raw) - } -} - -/// A custom `[[Prototype]]` installed on `arr` via `Object.setPrototypeOf` -/// that happens to be a real array — `None` for every other recorded shape -/// (which [`array_custom_prototype`] reports as [`ArrayCustomProto::Other`]). -unsafe fn array_custom_array_prototype_from_bits( - arr: *const ArrayHeader, - bits: u64, -) -> Option<*const ArrayHeader> { - let raw = pointer_bits_of_recorded_prototype(bits)?; - if raw < crate::gc::GC_HEADER_SIZE + 0x1000 || raw == arr as usize { - return None; - } - // A Proxy prototype is a small registered id, not a heap allocation — the - // GC-header read below would deref a fake pointer. - if crate::proxy::js_proxy_is_proxy(f64::from_bits(bits)) != 0 { - return None; - } - // #5625: the recorded prototype may be a *grown* array whose stored pointer - // was left FORWARDED by `js_array_grow` — its first 8 bytes now hold the - // forwarding pointer to the live head instead of length+capacity. (A real - // array grows when `Object.setPrototypeOf(arr, p)` captured `p` before a - // later push reallocated it, or the proto itself was built by appends — as - // in test262 copyWithin/coerced-values-start-change-start, whose - // `longDenseArray()` fills a `[0]` to 1024 elements.) Resolve the chain so - // we deref the current array head; reading the defunct old location yields - // the forwarding pointer's low 32 bits as a garbage `length`, making - // inherited-index reads silently miss (nondeterministic copyWithin output). - let resolved = clean_arr_ptr(raw as *const ArrayHeader); - if resolved.is_null() || resolved as usize == arr as usize { - return None; - } - let hdr = (resolved as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; - if (*hdr).obj_type == crate::gc::GC_TYPE_ARRAY { - Some(resolved) - } else { - None - } -} - -/// #9192: `[[Get]]`(index) through a NON-array custom `[[Prototype]]`, binding -/// `arr` as the receiver so an inherited index accessor sees the array as -/// `this`. `None` when the whole (replaced) chain lacks the index. -/// -/// Everything here allocates — `index.to_string()` interns a key, and the -/// resolver can run a user getter — so the array and the prototype are rooted -/// and re-read across the call. -unsafe fn array_object_proto_index_get( - arr: *const ArrayHeader, - proto_bits: u64, - index: u32, -) -> Option { - // The caller may still hold a pre-grow forwarding stub; the receiver an - // inherited accessor observes must be the live head. - let arr = clean_arr_ptr(arr); - if arr.is_null() { - return None; - } - let scope = crate::gc::RuntimeHandleScope::new(); - let receiver = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(arr as i64)); - let proto = scope.root_heap_word_u64(proto_bits); - let key = index.to_string(); - let key_hdr = crate::string::js_string_from_bytes(key.as_ptr(), key.len() as u32); - if key_hdr.is_null() { - return None; - } - let key_handle = scope.root_nanbox_f64(crate::value::nanbox_string_key(key_hdr)); - let receiver_addr = crate::value::js_nanbox_get_pointer(receiver.get_nanbox_f64()) as usize; - let key_ptr = crate::value::js_nanbox_get_pointer(key_handle.get_nanbox_f64()) - as *const crate::StringHeader; - crate::object::prototype_chain::resolve_inherited_field_from_prototype( - receiver_addr, - proto.get_heap_word_u64(), - key_ptr, - ) - .map(|v| f64::from_bits(v.bits())) -} - -/// #9192: the first object in a NON-array custom `[[Prototype]]` chain that -/// owns `key` with a descriptor — the owner whose accessor / attributes the -/// spec `Set` must observe before creating an own element on the array. A plain -/// writable data property carries no side-table entry and correctly reports no -/// owner: the Set then creates the own element, as the spec requires. -unsafe fn array_object_proto_index_owner(proto_bits: u64, key: &str) -> usize { - let mut bits = proto_bits; - for _ in 0..64 { - if bits == crate::value::TAG_NULL { - return 0; - } - if crate::proxy::js_proxy_is_proxy(f64::from_bits(bits)) != 0 { - return 0; - } - let Some(addr) = pointer_bits_of_recorded_prototype(bits) else { - return 0; - }; - // Pair the band predicate with the validity check (#6279): a handle - // value sits below HANDLE_BAND_MAX and would otherwise be dereferenced - // as if it were an object pointer. - if !crate::value::addr_class::is_above_handle_band(addr as usize) - || !crate::object::is_valid_obj_ptr(addr as *const u8) - { - return 0; - } - if crate::object::get_accessor_descriptor(addr, key).is_some() - || crate::object::get_property_attrs(addr, key).is_some() - { - return addr; - } - match crate::object::prototype_chain::object_static_prototype(addr) { - Some(next) => bits = next, - // #9220: `Object.create(p)` does NOT record `p` in the observable - // prototype side table — `js_object_create` models the link with a - // SYNTHETIC CLASS ID whose `class_prototype_object` entry is `p` - // (#809). The recorded-prototype hop alone therefore stops one link - // short, and an inherited accessor / non-writable index that the - // READ side already resolves (`js_object_get_field_by_name`'s - // `class_id != 0` branch, reached through - // `resolve_inherited_field_from_prototype`) was silently replaced by - // a new own element on the array. Take the same hop the read walk - // takes so `[[Set]]` and `[[Get]]` agree on the chain. - None => { - let class_id = (*(addr as *const crate::ObjectHeader)).class_id; - if class_id == 0 { - return 0; - } - let synth = crate::object::class_prototype_object(class_id); - if synth.is_null() || synth as usize == addr { - return 0; - } - bits = crate::value::js_nanbox_pointer(synth as i64).to_bits(); - } - } - } - 0 -} - -/// #9192: `[[HasProperty]]`(index) through a NON-array custom `[[Prototype]]`. -unsafe fn array_object_proto_index_has(proto_bits: u64, index: u32) -> bool { - let scope = crate::gc::RuntimeHandleScope::new(); - let proto = scope.root_heap_word_u64(proto_bits); - let key = index.to_string(); - let key_hdr = crate::string::js_string_from_bytes(key.as_ptr(), key.len() as u32); - if key_hdr.is_null() { - return false; - } - let key_handle = scope.root_nanbox_f64(crate::value::nanbox_string_key(key_hdr)); - let key_ptr = crate::value::js_nanbox_get_pointer(key_handle.get_nanbox_f64()) - as *const crate::StringHeader; - crate::object::prototype_value_has_property(proto.get_heap_word_u64(), key_ptr) -} - -/// Spec `[[Get]]`(O, ToString(index)) for an ordinary Array receiver: own value -/// (firing index accessors via `js_array_get_f64`) or, for an absent own index, -/// the inherited `Array.prototype[index]`. Returns `undefined` when absent. -pub(crate) fn array_spec_get(arr: *const ArrayHeader, index: u32) -> f64 { - const TAG_UNDEFINED_F64: f64 = f64::from_bits(0x7FFC_0000_0000_0001u64); - let arr = clean_arr_ptr(arr); - if arr.is_null() { - return TAG_UNDEFINED_F64; - } - unsafe { - let receiver = crate::value::js_nanbox_pointer(arr as i64); - let scope = crate::gc::RuntimeHandleScope::new(); - let receiver = scope.root_nanbox_f64(receiver); - if array_has_own_index(arr, index) { - return js_array_get_f64(arr, index); - } - // #9192: see `array_spec_has_index` — a non-array custom prototype - // replaces the default chain outright. - match array_custom_prototype(arr) { - Some(ArrayCustomProto::Null) => return TAG_UNDEFINED_F64, - Some(ArrayCustomProto::Other(bits)) => { - return array_object_proto_index_get(arr, bits, index).unwrap_or(TAG_UNDEFINED_F64) - } - Some(ArrayCustomProto::Array(proto_arr)) => { - if index < (*proto_arr).length && array_has_own_index(proto_arr, index) { - return array_inherited_index_get(proto_arr, index, receiver.get_nanbox_f64()); - } - } - None => {} - } - if ARRAY_PROTO_HAS_INDEX.load(Ordering::Relaxed) { - let proto = array_prototype_addr(); - if proto != 0 && proto != arr as usize { - let proto_arr = proto as *const ArrayHeader; - if index < (*proto_arr).length && array_has_own_index(proto_arr, index) { - return array_inherited_index_get(proto_arr, index, receiver.get_nanbox_f64()); - } - } - } - if OBJECT_PROTO_HAS_INDEX.load(Ordering::Relaxed) - && crate::array::object_prototype_has_index_prop(index) - { - return crate::array::sort_object_prototype_index_get_with_receiver( - index, - receiver.get_nanbox_f64(), - ); - } - TAG_UNDEFINED_F64 - } -} - -/// Spec `Set(O, ToString(index), value, true)` for an Array receiver. Unlike -/// the internal dense setter, this observes an inherited indexed accessor -/// before creating an own element. Array mutators use it on their exotic path -/// because a prototype setter may mutate the receiver (including freezing it -/// or making `length` non-writable) before the mutator's final length Set. -pub(crate) fn array_spec_set(arr: *mut ArrayHeader, index: u32, value: f64) -> *mut ArrayHeader { - let arr = clean_arr_ptr_mut(arr); - if arr.is_null() { - return arr; - } - let scope = crate::gc::RuntimeHandleScope::new(); - let arr_handle = scope.root_raw_mut_ptr(arr); - let value_handle = scope.root_nanbox_f64(value); - let receiver = - || crate::value::js_nanbox_pointer(arr_handle.get_raw_mut_ptr::() as i64); - let key = index.to_string(); - - unsafe { - if array_has_own_index(arr_handle.get_raw_mut_ptr::(), index) { - return js_array_set_f64_extend_strict_impl( - arr_handle.get_raw_mut_ptr::(), - index, - value_handle.get_nanbox_f64(), - true, - ); - } - - // #9192: a non-array custom `[[Prototype]]` owns the whole answer — its - // chain supplies the inherited accessor / non-writable attributes, and - // the implicit `Array.prototype` / `Object.prototype` tail below must - // not run. An explicit null prototype inherits nothing at all. - let mut default_chain = true; - let mut inherited_owner = 0usize; - match array_custom_prototype(arr_handle.get_raw_mut_ptr::()) { - Some(ArrayCustomProto::Null) => default_chain = false, - Some(ArrayCustomProto::Other(bits)) => { - default_chain = false; - inherited_owner = array_object_proto_index_owner(bits, &key); - } - Some(ArrayCustomProto::Array(proto_arr)) => { - if array_has_own_index(proto_arr, index) { - inherited_owner = proto_arr as usize; - } - } - None => {} - } - if inherited_owner == 0 && default_chain { - let proto = array_prototype_addr(); - inherited_owner = if proto != 0 - && proto != arr_handle.get_raw_mut_ptr::() as usize - && array_has_own_index(proto as *const ArrayHeader, index) - { - proto - } else if object_prototype_has_index_flag() - && crate::array::object_prototype_has_index_prop(index) - { - object_prototype_addr() - } else { - 0 - }; - } - - if inherited_owner != 0 { - if let Some(accessor) = crate::object::get_accessor_descriptor(inherited_owner, &key) { - if accessor.set == 0 { - crate::collection_iter::throw_type_error(&format!( - "Cannot set property {index} which has only a getter" - )); - } - crate::object::invoke_accessor_setter( - accessor.set, - receiver(), - value_handle.get_nanbox_f64(), - ); - return arr_handle.get_raw_mut_ptr::(); - } - if crate::object::get_property_attrs(inherited_owner, &key) - .is_some_and(|attrs| !attrs.writable()) - { - throw_frozen_array_index_write(index); - } - } - - js_array_set_f64_extend_strict_impl( - arr_handle.get_raw_mut_ptr::(), - index, - value_handle.get_nanbox_f64(), - true, - ) - } -} - -/// Read an own indexed property from an Array prototype while preserving the -/// original receiver for an inherited accessor's `this` value. -unsafe fn array_inherited_index_get( - proto_arr: *const ArrayHeader, - index: u32, - receiver: f64, -) -> f64 { - if array_object_flags(proto_arr) & crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS != 0 { - if let Some(acc) = - crate::object::get_accessor_descriptor(proto_arr as usize, &index.to_string()) - { - if acc.get != 0 { - return f64::from_bits( - crate::object::invoke_accessor_getter(acc.get, receiver).bits(), - ); - } - return f64::from_bits(crate::value::TAG_UNDEFINED); - } - } - js_array_get_f64(proto_arr, index) -} - pub(crate) fn array_get_property_by_key( arr: *const ArrayHeader, key: *const crate::StringHeader, diff --git a/crates/perry-runtime/src/array/indexing_proto_chain.rs b/crates/perry-runtime/src/array/indexing_proto_chain.rs new file mode 100644 index 0000000000..37468e4b51 --- /dev/null +++ b/crates/perry-runtime/src/array/indexing_proto_chain.rs @@ -0,0 +1,485 @@ +//! Spec-level indexed `[[Get]]` / `[[HasProperty]]` / `[[Set]]` for an Array +//! receiver, including the recorded custom `[[Prototype]]` classification +//! (#9192/#9219) and the inherited-descriptor walk an indexed assignment must +//! perform before it may create an own element (#9220/#9221). +//! +//! Split out of `indexing.rs` to keep that file under the repo's 2000-line cap; +//! a pure move. Declared as a CHILD of `indexing`, so parent-private helpers +//! (`clean_arr_ptr`, the prototype-index latches, the strict store entry) stay +//! reachable through `super::*` without widening any visibility. +use super::*; +use std::sync::atomic::Ordering; + +/// Out-of-bounds element read fallback: `Array.prototype[index]` when the +/// prototype has indexed properties (see `ARRAY_PROTO_HAS_INDEX`). Returns the +/// inherited value, or `undefined` if absent. Skipped entirely when the +/// receiver IS `Array.prototype` (avoids self-recursion) or the flag is unset. +/// +/// #6981: the `proto != receiver` self-recursion guard is an OBJECT IDENTITY +/// test, so both sides must be forwarding-resolved. `js_array_get_f64` resolves +/// its receiver through `clean_arr_ptr`; the prototype address comes from a +/// memoized cache, so it is healed here too. Comparing a stale address against +/// a resolved one makes the guard silently stop firing and +/// `js_array_get_f64` ⇄ this function recurse without bound. +#[inline] +pub(super) unsafe fn array_oob_prototype_get(receiver: usize, index: u32) -> f64 { + const TAG_UNDEFINED_F64: f64 = f64::from_bits(0x7FFC_0000_0000_0001u64); + // A custom [[Prototype]] (`Object.setPrototypeOf(arr, p)`) replaces the + // default chain — gated on a global relaxed flag. #9192: `p` need not be an + // array; a plain object / `Object.create(Array.prototype)` result answers + // the whole lookup through the generic resolver. + if crate::object::prototype_chain::array_static_proto_recorded() { + let arr = receiver as *const ArrayHeader; + match array_custom_prototype(arr) { + Some(ArrayCustomProto::Null) => return TAG_UNDEFINED_F64, + Some(ArrayCustomProto::Other(bits)) => { + return array_object_proto_index_get(arr, bits, index).unwrap_or(TAG_UNDEFINED_F64) + } + Some(ArrayCustomProto::Array(proto_arr)) => { + if index < (*proto_arr).length && array_has_own_index(proto_arr, index) { + return js_array_get_f64(proto_arr, index); + } + } + None => {} + } + } + if ARRAY_PROTO_HAS_INDEX.load(Ordering::Relaxed) { + let proto = array_prototype_addr(); + if proto != 0 && proto != crate::value::resolve_forwarding(receiver) { + let proto_arr = proto as *const ArrayHeader; + if index < (*proto_arr).length && array_has_own_index(proto_arr, index) { + return js_array_get_f64(proto_arr, index); + } + } + } + // Object.prototype indexed property (data or defineProperty accessor): + // arr → Array.prototype → Object.prototype (concat/S15.4.4.4_A3_T3). + if OBJECT_PROTO_HAS_INDEX.load(Ordering::Relaxed) + && crate::array::object_prototype_has_index_prop(index) + { + return crate::array::sort_object_prototype_index_get(index); + } + TAG_UNDEFINED_F64 +} + +/// Spec `[[HasProperty]]`(O, ToString(index)) for an ordinary Array receiver: +/// own property OR inherited indexed property from `Array.prototype`. +pub(crate) fn array_spec_has_index(arr: *const ArrayHeader, index: u32) -> bool { + let arr = clean_arr_ptr(arr); + if arr.is_null() { + return false; + } + unsafe { + if array_has_own_index(arr, index) { + return true; + } + // An explicit `Object.setPrototypeOf(arr, p)` REPLACES the default + // chain. A real-array `p` keeps the original lane (its own indices + // first, then the implicit `Array.prototype` tail below — test262 + // copyWithin/coerced-values-start-change-*). #9192: any other `p` + // answers the whole question by itself, so the default-chain tail must + // not run after it. + match array_custom_prototype(arr) { + Some(ArrayCustomProto::Null) => return false, + Some(ArrayCustomProto::Other(bits)) => { + return array_object_proto_index_has(bits, index) + } + Some(ArrayCustomProto::Array(proto_arr)) => { + if index < (*proto_arr).length && array_has_own_index(proto_arr, index) { + return true; + } + } + None => {} + } + if ARRAY_PROTO_HAS_INDEX.load(Ordering::Relaxed) { + let proto = array_prototype_addr(); + if proto != 0 && proto != arr as usize { + let proto_arr = proto as *const ArrayHeader; + if index < (*proto_arr).length && array_has_own_index(proto_arr, index) { + return true; + } + } + } + if OBJECT_PROTO_HAS_INDEX.load(Ordering::Relaxed) + && crate::array::object_prototype_has_index_prop(index) + { + return true; + } + false + } +} + +/// How a recorded custom `[[Prototype]]` on a real array must be consulted. +/// +/// #9192: before this classification the array index paths accepted a recorded +/// prototype ONLY when it was itself a `GC_TYPE_ARRAY`; every other shape (a +/// plain object, an `Object.create(Array.prototype)` result, a class prototype) +/// was recorded — latching the process-wide index deopt — and then silently +/// ignored, so the array inherited nothing at all. +pub(crate) enum ArrayCustomProto { + /// `Object.setPrototypeOf(arr, null)`: nothing is inherited, and the + /// implicit `Array.prototype` → `Object.prototype` chain is gone too. + Null, + /// The recorded prototype is itself a real array — the original lane, kept + /// bit-for-bit (test262 copyWithin/coerced-values-start-change-*). + Array(*const ArrayHeader), + /// Any other object: resolved through the generic object machinery with the + /// array as the receiver, so prototype accessors see the right `this` and + /// further hops (`Object.create(Array.prototype)`, proxies) are walked. + Other(u64), +} + +/// Classify the `[[Prototype]]` an explicit `Object.setPrototypeOf` / +/// `__proto__` / `Reflect.setPrototypeOf` recorded for `arr`. `None` when the +/// array still carries the default `Array.prototype` chain. +pub(crate) unsafe fn array_custom_prototype(arr: *const ArrayHeader) -> Option { + let bits = crate::object::prototype_chain::object_static_prototype(arr as usize)?; + if bits == crate::value::TAG_NULL { + return Some(ArrayCustomProto::Null); + } + if let Some(proto_arr) = array_custom_array_prototype_from_bits(arr, bits) { + return Some(ArrayCustomProto::Array(proto_arr)); + } + // A Proxy prototype keeps its existing dedicated handling in the `in` / + // property-get arms; routing it through the generic resolver here as well + // would invoke the `has` trap twice, which is observable. + if crate::proxy::js_proxy_is_proxy(f64::from_bits(bits)) != 0 { + return None; + } + // A pointer-shaped record that is not a real array is the #9192 case. A + // record that is not pointer-shaped at all (a stale/garbage entry) is + // reported as "no custom prototype", exactly as before. + pointer_bits_of_recorded_prototype(bits).map(|_| ArrayCustomProto::Other(bits)) +} + +/// The heap address a recorded prototype's bits name, if they are pointer +/// shaped at all. The record may be NaN-boxed (0x7FFD) or a RAW untagged +/// pointer (module-level arrays are stored as raw I64s). +fn pointer_bits_of_recorded_prototype(bits: u64) -> Option { + let raw = if (bits >> 48) == 0x7FFD { + (bits & crate::value::POINTER_MASK) as usize + } else if (bits >> 48) == 0 && bits > 0x10000 { + bits as usize + } else { + return None; + }; + if raw == 0 { + None + } else { + Some(raw) + } +} + +/// A custom `[[Prototype]]` installed on `arr` via `Object.setPrototypeOf` +/// that happens to be a real array — `None` for every other recorded shape +/// (which [`array_custom_prototype`] reports as [`ArrayCustomProto::Other`]). +unsafe fn array_custom_array_prototype_from_bits( + arr: *const ArrayHeader, + bits: u64, +) -> Option<*const ArrayHeader> { + let raw = pointer_bits_of_recorded_prototype(bits)?; + if raw < crate::gc::GC_HEADER_SIZE + 0x1000 || raw == arr as usize { + return None; + } + // A Proxy prototype is a small registered id, not a heap allocation — the + // GC-header read below would deref a fake pointer. + if crate::proxy::js_proxy_is_proxy(f64::from_bits(bits)) != 0 { + return None; + } + // #5625: the recorded prototype may be a *grown* array whose stored pointer + // was left FORWARDED by `js_array_grow` — its first 8 bytes now hold the + // forwarding pointer to the live head instead of length+capacity. (A real + // array grows when `Object.setPrototypeOf(arr, p)` captured `p` before a + // later push reallocated it, or the proto itself was built by appends — as + // in test262 copyWithin/coerced-values-start-change-start, whose + // `longDenseArray()` fills a `[0]` to 1024 elements.) Resolve the chain so + // we deref the current array head; reading the defunct old location yields + // the forwarding pointer's low 32 bits as a garbage `length`, making + // inherited-index reads silently miss (nondeterministic copyWithin output). + let resolved = clean_arr_ptr(raw as *const ArrayHeader); + if resolved.is_null() || resolved as usize == arr as usize { + return None; + } + let hdr = (resolved as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; + if (*hdr).obj_type == crate::gc::GC_TYPE_ARRAY { + Some(resolved) + } else { + None + } +} + +/// #9192: `[[Get]]`(index) through a NON-array custom `[[Prototype]]`, binding +/// `arr` as the receiver so an inherited index accessor sees the array as +/// `this`. `None` when the whole (replaced) chain lacks the index. +/// +/// Everything here allocates — `index.to_string()` interns a key, and the +/// resolver can run a user getter — so the array and the prototype are rooted +/// and re-read across the call. +unsafe fn array_object_proto_index_get( + arr: *const ArrayHeader, + proto_bits: u64, + index: u32, +) -> Option { + // The caller may still hold a pre-grow forwarding stub; the receiver an + // inherited accessor observes must be the live head. + let arr = clean_arr_ptr(arr); + if arr.is_null() { + return None; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(arr as i64)); + let proto = scope.root_heap_word_u64(proto_bits); + let key = index.to_string(); + let key_hdr = crate::string::js_string_from_bytes(key.as_ptr(), key.len() as u32); + if key_hdr.is_null() { + return None; + } + let key_handle = scope.root_nanbox_f64(crate::value::nanbox_string_key(key_hdr)); + let receiver_addr = crate::value::js_nanbox_get_pointer(receiver.get_nanbox_f64()) as usize; + let key_ptr = crate::value::js_nanbox_get_pointer(key_handle.get_nanbox_f64()) + as *const crate::StringHeader; + crate::object::prototype_chain::resolve_inherited_field_from_prototype( + receiver_addr, + proto.get_heap_word_u64(), + key_ptr, + ) + .map(|v| f64::from_bits(v.bits())) +} + +/// #9192: the first object in a NON-array custom `[[Prototype]]` chain that +/// owns `key` with a descriptor — the owner whose accessor / attributes the +/// spec `Set` must observe before creating an own element on the array. A plain +/// writable data property carries no side-table entry and correctly reports no +/// owner: the Set then creates the own element, as the spec requires. +unsafe fn array_object_proto_index_owner(proto_bits: u64, key: &str) -> usize { + let mut bits = proto_bits; + for _ in 0..64 { + if bits == crate::value::TAG_NULL { + return 0; + } + if crate::proxy::js_proxy_is_proxy(f64::from_bits(bits)) != 0 { + return 0; + } + let Some(addr) = pointer_bits_of_recorded_prototype(bits) else { + return 0; + }; + // Pair the band predicate with the validity check (#6279): a handle + // value sits below HANDLE_BAND_MAX and would otherwise be dereferenced + // as if it were an object pointer. + if !crate::value::addr_class::is_above_handle_band(addr as usize) + || !crate::object::is_valid_obj_ptr(addr as *const u8) + { + return 0; + } + if crate::object::get_accessor_descriptor(addr, key).is_some() + || crate::object::get_property_attrs(addr, key).is_some() + { + return addr; + } + match crate::object::prototype_chain::object_static_prototype(addr) { + Some(next) => bits = next, + // #9220: `Object.create(p)` does NOT record `p` in the observable + // prototype side table — `js_object_create` models the link with a + // SYNTHETIC CLASS ID whose `class_prototype_object` entry is `p` + // (#809). The recorded-prototype hop alone therefore stops one link + // short, and an inherited accessor / non-writable index that the + // READ side already resolves (`js_object_get_field_by_name`'s + // `class_id != 0` branch, reached through + // `resolve_inherited_field_from_prototype`) was silently replaced by + // a new own element on the array. Take the same hop the read walk + // takes so `[[Set]]` and `[[Get]]` agree on the chain. + None => { + let class_id = (*(addr as *const crate::ObjectHeader)).class_id; + if class_id == 0 { + return 0; + } + let synth = crate::object::class_prototype_object(class_id); + if synth.is_null() || synth as usize == addr { + return 0; + } + bits = crate::value::js_nanbox_pointer(synth as i64).to_bits(); + } + } + } + 0 +} + +/// #9192: `[[HasProperty]]`(index) through a NON-array custom `[[Prototype]]`. +unsafe fn array_object_proto_index_has(proto_bits: u64, index: u32) -> bool { + let scope = crate::gc::RuntimeHandleScope::new(); + let proto = scope.root_heap_word_u64(proto_bits); + let key = index.to_string(); + let key_hdr = crate::string::js_string_from_bytes(key.as_ptr(), key.len() as u32); + if key_hdr.is_null() { + return false; + } + let key_handle = scope.root_nanbox_f64(crate::value::nanbox_string_key(key_hdr)); + let key_ptr = crate::value::js_nanbox_get_pointer(key_handle.get_nanbox_f64()) + as *const crate::StringHeader; + crate::object::prototype_value_has_property(proto.get_heap_word_u64(), key_ptr) +} + +/// Spec `[[Get]]`(O, ToString(index)) for an ordinary Array receiver: own value +/// (firing index accessors via `js_array_get_f64`) or, for an absent own index, +/// the inherited `Array.prototype[index]`. Returns `undefined` when absent. +pub(crate) fn array_spec_get(arr: *const ArrayHeader, index: u32) -> f64 { + const TAG_UNDEFINED_F64: f64 = f64::from_bits(0x7FFC_0000_0000_0001u64); + let arr = clean_arr_ptr(arr); + if arr.is_null() { + return TAG_UNDEFINED_F64; + } + unsafe { + let receiver = crate::value::js_nanbox_pointer(arr as i64); + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver = scope.root_nanbox_f64(receiver); + if array_has_own_index(arr, index) { + return js_array_get_f64(arr, index); + } + // #9192: see `array_spec_has_index` — a non-array custom prototype + // replaces the default chain outright. + match array_custom_prototype(arr) { + Some(ArrayCustomProto::Null) => return TAG_UNDEFINED_F64, + Some(ArrayCustomProto::Other(bits)) => { + return array_object_proto_index_get(arr, bits, index).unwrap_or(TAG_UNDEFINED_F64) + } + Some(ArrayCustomProto::Array(proto_arr)) => { + if index < (*proto_arr).length && array_has_own_index(proto_arr, index) { + return array_inherited_index_get(proto_arr, index, receiver.get_nanbox_f64()); + } + } + None => {} + } + if ARRAY_PROTO_HAS_INDEX.load(Ordering::Relaxed) { + let proto = array_prototype_addr(); + if proto != 0 && proto != arr as usize { + let proto_arr = proto as *const ArrayHeader; + if index < (*proto_arr).length && array_has_own_index(proto_arr, index) { + return array_inherited_index_get(proto_arr, index, receiver.get_nanbox_f64()); + } + } + } + if OBJECT_PROTO_HAS_INDEX.load(Ordering::Relaxed) + && crate::array::object_prototype_has_index_prop(index) + { + return crate::array::sort_object_prototype_index_get_with_receiver( + index, + receiver.get_nanbox_f64(), + ); + } + TAG_UNDEFINED_F64 + } +} + +/// Spec `Set(O, ToString(index), value, true)` for an Array receiver. Unlike +/// the internal dense setter, this observes an inherited indexed accessor +/// before creating an own element. Array mutators use it on their exotic path +/// because a prototype setter may mutate the receiver (including freezing it +/// or making `length` non-writable) before the mutator's final length Set. +pub(crate) fn array_spec_set(arr: *mut ArrayHeader, index: u32, value: f64) -> *mut ArrayHeader { + let arr = clean_arr_ptr_mut(arr); + if arr.is_null() { + return arr; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let arr_handle = scope.root_raw_mut_ptr(arr); + let value_handle = scope.root_nanbox_f64(value); + let receiver = + || crate::value::js_nanbox_pointer(arr_handle.get_raw_mut_ptr::() as i64); + let key = index.to_string(); + + unsafe { + if array_has_own_index(arr_handle.get_raw_mut_ptr::(), index) { + return js_array_set_f64_extend_strict_impl( + arr_handle.get_raw_mut_ptr::(), + index, + value_handle.get_nanbox_f64(), + true, + ); + } + + // #9192: a non-array custom `[[Prototype]]` owns the whole answer — its + // chain supplies the inherited accessor / non-writable attributes, and + // the implicit `Array.prototype` / `Object.prototype` tail below must + // not run. An explicit null prototype inherits nothing at all. + let mut default_chain = true; + let mut inherited_owner = 0usize; + match array_custom_prototype(arr_handle.get_raw_mut_ptr::()) { + Some(ArrayCustomProto::Null) => default_chain = false, + Some(ArrayCustomProto::Other(bits)) => { + default_chain = false; + inherited_owner = array_object_proto_index_owner(bits, &key); + } + Some(ArrayCustomProto::Array(proto_arr)) => { + if array_has_own_index(proto_arr, index) { + inherited_owner = proto_arr as usize; + } + } + None => {} + } + if inherited_owner == 0 && default_chain { + let proto = array_prototype_addr(); + inherited_owner = if proto != 0 + && proto != arr_handle.get_raw_mut_ptr::() as usize + && array_has_own_index(proto as *const ArrayHeader, index) + { + proto + } else if object_prototype_has_index_flag() + && crate::array::object_prototype_has_index_prop(index) + { + object_prototype_addr() + } else { + 0 + }; + } + + if inherited_owner != 0 { + if let Some(accessor) = crate::object::get_accessor_descriptor(inherited_owner, &key) { + if accessor.set == 0 { + crate::collection_iter::throw_type_error(&format!( + "Cannot set property {index} which has only a getter" + )); + } + crate::object::invoke_accessor_setter( + accessor.set, + receiver(), + value_handle.get_nanbox_f64(), + ); + return arr_handle.get_raw_mut_ptr::(); + } + if crate::object::get_property_attrs(inherited_owner, &key) + .is_some_and(|attrs| !attrs.writable()) + { + throw_frozen_array_index_write(index); + } + } + + js_array_set_f64_extend_strict_impl( + arr_handle.get_raw_mut_ptr::(), + index, + value_handle.get_nanbox_f64(), + true, + ) + } +} + +/// Read an own indexed property from an Array prototype while preserving the +/// original receiver for an inherited accessor's `this` value. +unsafe fn array_inherited_index_get( + proto_arr: *const ArrayHeader, + index: u32, + receiver: f64, +) -> f64 { + if array_object_flags(proto_arr) & crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS != 0 { + if let Some(acc) = + crate::object::get_accessor_descriptor(proto_arr as usize, &index.to_string()) + { + if acc.get != 0 { + return f64::from_bits( + crate::object::invoke_accessor_getter(acc.get, receiver).bits(), + ); + } + return f64::from_bits(crate::value::TAG_UNDEFINED); + } + } + js_array_get_f64(proto_arr, index) +} diff --git a/scripts/addr_class_allowlist.txt b/scripts/addr_class_allowlist.txt index d9f317b916..7ca573b098 100644 --- a/scripts/addr_class_allowlist.txt +++ b/scripts/addr_class_allowlist.txt @@ -34,6 +34,7 @@ crates/perry-runtime/src/array/generic.rs | * | pre-existing GcHeader probe pred crates/perry-runtime/src/array/header.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up crates/perry-runtime/src/array/indexing.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up crates/perry-runtime/src/array/indexing_keyed.rs | * | same pre-existing GcHeader probe, moved from indexing.rs by the 2,000-line file split (js_array_set_string_key); migrate to addr_class::try_read_gc_header in a follow-up +crates/perry-runtime/src/array/indexing_proto_chain.rs | * | same pre-existing GcHeader probe, moved from indexing.rs by the 2,000-line file split (the spec [[Get]]/[[HasProperty]]/[[Set]] prototype-chain block); migrate to addr_class::try_read_gc_header in a follow-up crates/perry-runtime/src/array/is_array.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up crates/perry-runtime/src/array/iter_methods.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up crates/perry-runtime/src/array/iter_object.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up diff --git a/scripts/addr_class_ratchet_baseline.txt b/scripts/addr_class_ratchet_baseline.txt index f476ad620e..136e25806a 100644 --- a/scripts/addr_class_ratchet_baseline.txt +++ b/scripts/addr_class_ratchet_baseline.txt @@ -32,8 +32,9 @@ handle-floor | crates/perry-runtime/src/array/concat_reverse.rs | 1 handle-floor | crates/perry-runtime/src/array/flat_clone.rs | 4 handle-floor | crates/perry-runtime/src/array/generic.rs | 4 handle-floor | crates/perry-runtime/src/array/header.rs | 3 -handle-floor | crates/perry-runtime/src/array/indexing.rs | 3 +handle-floor | crates/perry-runtime/src/array/indexing.rs | 2 handle-floor | crates/perry-runtime/src/array/indexing_keyed.rs | 1 +handle-floor | crates/perry-runtime/src/array/indexing_proto_chain.rs | 1 handle-floor | crates/perry-runtime/src/array/iter_object.rs | 1 handle-floor | crates/perry-runtime/src/array/iterator.rs | 2 handle-floor | crates/perry-runtime/src/array/push_pop.rs | 1