diff --git a/changelog.d/10229-fractional-array-index.md b/changelog.d/10229-fractional-array-index.md new file mode 100644 index 0000000000..05013c7185 --- /dev/null +++ b/changelog.d/10229-fractional-array-index.md @@ -0,0 +1,5 @@ +Fix fractional numeric reads on arrays with erased receiver types, including +lazy JSON arrays: `rows[0.5]` now looks up the property `"0.5"` instead of +truncating to element zero. The runtime fallback validates the integer range +before narrowing, preserving negative, non-finite, and large property keys +and reads of named properties without changing the compiler's inline tiers. diff --git a/crates/perry-runtime/src/array/dynamic_numeric_key_tests.rs b/crates/perry-runtime/src/array/dynamic_numeric_key_tests.rs new file mode 100644 index 0000000000..edf23709eb --- /dev/null +++ b/crates/perry-runtime/src/array/dynamic_numeric_key_tests.rs @@ -0,0 +1,120 @@ +//! #10190: the erased-receiver fallback must preserve the original numeric key. + +use super::subclass::js_packed_arraylike_index_get; +use crate::value::js_dyn_index_get; +use crate::value::{JSValue, TAG_UNDEFINED}; + +fn receiver(state: u8) -> f64 { + if state == 0 { + let mut array = crate::array::js_array_alloc(3); + for value in [11.0, 22.0, 33.0] { + array = crate::array::js_array_push_f64(array, value); + } + return crate::value::js_nanbox_pointer(array as i64); + } + let bytes = b"[11,22,33]"; + let tape = crate::json_tape::build_tape(bytes).unwrap(); + let text = crate::string::js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); + let scope = crate::gc::RuntimeHandleScope::new(); + let array = scope + .root_raw_mut_ptr(unsafe { crate::json_tape::alloc_lazy_array(&tape.entries, 0, 3, text) }); + if state == 2 { + unsafe { crate::json_tape::force_materialize_lazy(array.get_raw_mut_ptr()) }; + } + let raw = array.get_raw_const_ptr::(); + let header = unsafe { crate::value::addr_class::try_read_gc_header(raw as usize) }.unwrap(); + assert_eq!(header.obj_type, crate::gc::GC_TYPE_LAZY_ARRAY); + assert_eq!(unsafe { !(*raw).materialized.is_null() }, state == 2); + crate::value::js_nanbox_pointer(raw as i64) +} + +#[test] +fn non_element_numeric_keys_do_not_truncate_on_regular_lazy_or_materialized_arrays() { + for state in 0..3 { + let scope = crate::gc::RuntimeHandleScope::new(); + let array = scope.root_nanbox_f64(receiver(state)); + for key in [ + 0.5, + -0.5, + 1.5, + -1.0, + 2_147_483_648.0, + 4_294_967_295.0, + f64::NAN, + f64::INFINITY, + f64::NEG_INFINITY, + ] { + assert_eq!( + js_dyn_index_get(array.get_nanbox_f64(), key).to_bits(), + TAG_UNDEFINED, + "state={state}, key={key}" + ); + assert_eq!( + js_packed_arraylike_index_get(array.get_nanbox_f64(), key, std::ptr::null_mut()) + .to_bits(), + TAG_UNDEFINED, + "packed state={state}, key={key}" + ); + } + for (key, expected) in [ + (0.0, 11.0), + (-0.0, 11.0), + (1.0, 22.0), + (f64::from_bits(JSValue::int32(2).bits()), 33.0), + ] { + assert_eq!(js_dyn_index_get(array.get_nanbox_f64(), key), expected); + assert_eq!( + js_packed_arraylike_index_get(array.get_nanbox_f64(), key, std::ptr::null_mut()), + expected + ); + } + } +} + +#[test] +fn numeric_named_properties_remain_readable_through_the_dynamic_fallback() { + let scope = crate::gc::RuntimeHandleScope::new(); + let array = scope.root_nanbox_f64(receiver(0)); + for (key, name) in [ + (0.5, "0.5"), + (-0.5, "-0.5"), + (-1.0, "-1"), + (4_294_967_295.0, "4294967295"), + (f64::NAN, "NaN"), + (f64::INFINITY, "Infinity"), + (1e21, "1e+21"), + ] { + let name = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let raw = JSValue::from_bits(array.get_nanbox_f64().to_bits()) + .as_pointer::() + as *mut crate::array::ArrayHeader; + crate::array::js_array_set_string_key(raw, name, 91.0); + assert_eq!( + js_dyn_index_get(array.get_nanbox_f64(), key), + 91.0, + "key={key}" + ); + assert_eq!( + js_packed_arraylike_index_get(array.get_nanbox_f64(), key, std::ptr::null_mut()), + 91.0, + "packed key={key}" + ); + } + assert_eq!(js_dyn_index_get(array.get_nanbox_f64(), 0.0), 11.0); +} + +#[test] +fn boxed_integer_keys_keep_their_object_property_semantics() { + let scope = crate::gc::RuntimeHandleScope::new(); + let object = scope.root_raw_mut_ptr(crate::object::js_object_alloc(0, 0)); + let key = crate::string::js_string_from_bytes(b"2".as_ptr(), 1); + crate::object::js_object_set_field_by_name(object.get_raw_mut_ptr(), key, 42.0); + let read = |key| { + let receiver = crate::value::js_nanbox_pointer( + object.get_raw_const_ptr::() as i64, + ); + js_dyn_index_get(receiver, key) + }; + assert_eq!(read(2.0), 42.0); + assert_eq!(read(f64::from_bits(JSValue::int32(2).bits())), 42.0); +} diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index fc2c981e47..76539179fa 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -56,6 +56,8 @@ mod collection_tag_tests; #[cfg(test)] mod dense_move_tests; #[cfg(test)] +mod dynamic_numeric_key_tests; +#[cfg(test)] mod forwarding_tests; #[cfg(test)] mod index_get_exit_tests; diff --git a/crates/perry-runtime/src/value/dyn_index.rs b/crates/perry-runtime/src/value/dyn_index.rs index 7d267faa82..495f96232b 100644 --- a/crates/perry-runtime/src/value/dyn_index.rs +++ b/crates/perry-runtime/src/value/dyn_index.rs @@ -408,13 +408,14 @@ pub extern "C" fn js_dyn_index_get(value: f64, index: f64) -> f64 { ); } } - // NaN and +/-Infinity are not array indices, but they are still ordinary - // property keys (`"NaN"`, `"Infinity"`, `"-Infinity"`) on Objects and - // Arrays. Delegate this cold case to the polymorphic key path, which runs - // ToPropertyKey and already distinguishes ordinary from integer-indexed - // exotic receivers. The old early return made a computed definition such - // as `{ [Infinity]: value }` unreadable through `obj[Infinity]`. - if index.is_nan() || index.is_infinite() { + // The element path below takes an i32, so prove an exact non-negative + // index before narrowing. Fractional and negative keys are ordinary + // properties: truncating 0.5 (or -0.5) would read element 0 (#10190). + // Preserve the original key for ToPropertyKey, including non-finite and + // large values; the polymorphic path also handles valid u32 indices + // beyond i32::MAX. NaN-boxed INT32 keys retain their existing decoding + // in that dispatcher, rather than entering the raw-f64 path below. + if !index.is_finite() || index < 0.0 || index > i32::MAX as f64 || index.fract() != 0.0 { return crate::object::js_object_get_index_polymorphic(raw_ptr as i64, index); } let idx_i32 = index as i32; diff --git a/test-files/test_gap_10190_fractional_array_index.ts b/test-files/test_gap_10190_fractional_array_index.ts new file mode 100644 index 0000000000..5217c5e31f --- /dev/null +++ b/test-files/test_gap_10190_fractional_array_index.ts @@ -0,0 +1,32 @@ +// #10190: numeric keys that are not array indices are named properties. +function read(receiver: any, key: any): any { return receiver[key]; } +function numeric(receiver: any, key: number): any { return receiver[key]; } +function typed(receiver: number[], key: number): any { return receiver[key]; } + +const rows: any = JSON.parse('[{"id":1},{"id":2},{"id":3}]'); +console.log("repro", rows[0.5], rows[-0.5]); +try { + console.log(rows[0.5].id); +} catch (error) { + console.log("repro throws", error instanceof TypeError); +} + +const keys = [0.5, -0.5, 1.5, -1, NaN, Infinity, -Infinity, + 2147483648, 4294967295, 0, -0, 1, 2, 3]; +for (const array of [[11, 22, 33], JSON.parse('[11,22,33]')]) { + for (const key of keys) { + console.log("absent", key, read(array, key), numeric(array, key), typed(array, key)); + } + for (let i = 0; i < 9; i++) { + const key = keys[i]; + Object.defineProperty(array, String(key), { value: 100 + i, configurable: true }); + console.log("own", key, read(array, key), numeric(array, key), typed(array, key)); + } + console.log("elements", array.length, array[0], array[1], array[2]); +} + +const inherited: any = [11, 22]; +const prototype = Object.create(Array.prototype); +Object.defineProperty(prototype, "0.5", { get() { return this[1] + 7; } }); +Object.setPrototypeOf(inherited, prototype); +console.log("inherited", read(inherited, 0.5), numeric(inherited, 0.5));