diff --git a/changelog.d/9376-large-presized-array-fill.md b/changelog.d/9376-large-presized-array-fill.md new file mode 100644 index 0000000000..2f4071db23 --- /dev/null +++ b/changelog.d/9376-large-presized-array-fill.md @@ -0,0 +1,3 @@ +Large pre-sized arrays now materialize their dense backing storage incrementally +during sequential indexed writes instead of falling back to quadratic sparse +property insertion. Growth also preserves expandos and GC-traced element slots. diff --git a/crates/perry-codegen/src/expr/index.rs b/crates/perry-codegen/src/expr/index.rs index 2a94aed43b..cd4510602e 100644 --- a/crates/perry-codegen/src/expr/index.rs +++ b/crates/perry-codegen/src/expr/index.rs @@ -294,9 +294,13 @@ pub(crate) fn lower_index_set_fast( let hdr_capacity = blk.load(I32, &cap_ptr); let index_nonnegative = blk.icmp_slt(I32, &idx_i32, "0"); let index_nonnegative = blk.icmp_eq(I1, &index_nonnegative, "false"); - let length_sane = blk.icmp_ule(I32, &hdr_length, "16000000"); let capacity_sane = blk.icmp_ule(I32, &hdr_capacity, "16000000"); let length_within_capacity = blk.icmp_ule(I32, &hdr_length, &hdr_capacity); + let index_within_capacity = blk.icmp_ult(I32, &idx_i32, &hdr_capacity); + // #9371: `new Array(largeLength)` intentionally has length above + // capacity. A store into its allocated prefix is still safe; only + // the boundary write needs the runtime to grow that prefix. + let storage_safe = blk.or(I1, &length_within_capacity, &index_within_capacity); let mut guard_ok = blk.and(I1, &is_array, ¬_forwarded); guard_ok = blk.and(I1, &guard_ok, &integrity_clean); @@ -312,9 +316,8 @@ pub(crate) fn lower_index_set_fast( } guard_ok = blk.and(I1, &guard_ok, &default_prototype_chain); guard_ok = blk.and(I1, &guard_ok, &index_nonnegative); - guard_ok = blk.and(I1, &guard_ok, &length_sane); guard_ok = blk.and(I1, &guard_ok, &capacity_sane); - guard_ok = blk.and(I1, &guard_ok, &length_within_capacity); + guard_ok = blk.and(I1, &guard_ok, &storage_safe); // #9237: kept exactly where it is load-bearing. The comment below // is about the RAW store — a `number[]` slot can genuinely receive a // non-number at runtime, and writing its NaN-boxed tag verbatim as a diff --git a/crates/perry-codegen/src/expr/index_set_guarded.rs b/crates/perry-codegen/src/expr/index_set_guarded.rs index afb3d4e8ca..3a898592ea 100644 --- a/crates/perry-codegen/src/expr/index_set_guarded.rs +++ b/crates/perry-codegen/src/expr/index_set_guarded.rs @@ -179,18 +179,18 @@ pub(super) fn emit_guarded_inbounds_array_store( // STRICTLY in bounds: `index == length` is an extend, which changes // `length` and may need a realloc, so it belongs on the slow arm. let index_in_bounds = blk.icmp_ult(I32, idx_i32, &length); - let length_sane = blk.icmp_ule(I32, &length, "16000000"); let capacity_sane = blk.icmp_ule(I32, &capacity, "16000000"); - let length_within_capacity = blk.icmp_ule(I32, &length, &capacity); + let index_within_capacity = blk.icmp_ult(I32, idx_i32, &capacity); let mut guard_ok = blk.and(I1, &is_array, ¬_forwarded); guard_ok = blk.and(I1, &guard_ok, &integrity_clean); guard_ok = blk.and(I1, &guard_ok, &default_prototype_chain); guard_ok = blk.and(I1, &guard_ok, &index_nonnegative); guard_ok = blk.and(I1, &guard_ok, &index_in_bounds); - guard_ok = blk.and(I1, &guard_ok, &length_sane); guard_ok = blk.and(I1, &guard_ok, &capacity_sane); - guard_ok = blk.and(I1, &guard_ok, &length_within_capacity); + // #9371: a large pre-sized holey Array has `length > capacity`, but + // an in-bounds index below capacity still names allocated storage. + guard_ok = blk.and(I1, &guard_ok, &index_within_capacity); blk.cond_br(&guard_ok, &fast_label, &slow_label); // The live head's `_reserved` word dominates the fast arm; the numeric // write note below is gated on it. diff --git a/crates/perry-runtime/src/array/header.rs b/crates/perry-runtime/src/array/header.rs index 7c449a53ef..ec23d3aa85 100644 --- a/crates/perry-runtime/src/array/header.rs +++ b/crates/perry-runtime/src/array/header.rs @@ -348,6 +348,26 @@ fn merge_array_named_props( barrier_array_named_props(owner, entry); } +/// Rekey an array's named-property side table when `js_array_grow` replaces +/// its inline allocation. GC moves do this through the mutable-root visitor; +/// array growth is outside the collector and must perform the same ownership +/// transfer explicitly before publishing the forwarding stub. +pub(crate) fn transfer_array_named_property_owner(old_owner: usize, new_owner: usize) { + if old_owner == 0 + || new_owner == 0 + || old_owner == new_owner + || !ARRAY_NAMED_PROPS_EVER.load(std::sync::atomic::Ordering::Acquire) + { + return; + } + ARRAY_NAMED_PROPS.with(|m| { + let mut props = m.borrow_mut(); + if let Some(old_props) = props.remove(&old_owner) { + merge_array_named_props(&mut props, new_owner, old_props); + } + }); +} + pub(crate) fn scan_array_named_property_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { ARRAY_NAMED_PROPS.with(|m| { let mut props = m.borrow_mut(); @@ -508,6 +528,24 @@ pub(crate) unsafe fn array_has_named_properties_resolved(arr: *const ArrayHeader }) } +/// Whether an already-resolved array owns numeric indices in the named-property +/// side table. Those indices live beyond the dense allocation. Growing the +/// allocation across one without migrating it would hide the property because +/// indexed reads consult the side table only at `index >= capacity`. +#[inline] +pub(crate) unsafe fn array_has_sparse_index_properties_resolved(arr: *const ArrayHeader) -> bool { + if !ARRAY_NAMED_PROPS_EVER.load(std::sync::atomic::Ordering::Acquire) { + return false; + } + ARRAY_NAMED_PROPS.with(|m| { + m.borrow().get(&(arr as usize)).is_some_and(|props| { + props + .iter() + .any(|prop| crate::object::canonical_array_index(&prop.name).is_some()) + }) + }) +} + pub(crate) unsafe fn array_named_property_get( arr: *const ArrayHeader, key: *const crate::StringHeader, @@ -798,9 +836,19 @@ pub(crate) fn clean_arr_ptr(arr: *const ArrayHeader) -> *const ArrayHeader { // wave them through; everything else at this size is // almost certainly corrupted. let addr = cleaned as usize; + // #9371: a large pre-sized holey array grows its dense prefix on + // demand, so a legitimate sparse header can have capacity above + // the old one-million cutoff while it is still below `length`. + // Prove the capacity against the tracked allocation's exact byte + // size instead of imposing a second semantic threshold. Corrupt + // length/capacity words still fail closed unless they describe + // precisely the allocation the GC owns at this address. let sparse_array_shape = tracked_obj_type == Some(crate::gc::GC_TYPE_ARRAY) && hdr.length > hdr.capacity - && hdr.capacity <= 1_000_000; + && tracked_header.is_some_and(|gc_header| { + checked_array_allocation_size(hdr.capacity as usize) + == Some((*gc_header.as_ptr()).size as usize) + }); if sparse_array_shape { return cleaned; } @@ -1910,6 +1958,14 @@ pub(crate) fn array_byte_size(capacity: usize) -> usize { std::mem::size_of::() + capacity * std::mem::size_of::() } +#[inline] +pub(super) fn checked_array_allocation_size(capacity: usize) -> Option { + capacity + .checked_mul(std::mem::size_of::()) + .and_then(|elements| std::mem::size_of::().checked_add(elements)) + .and_then(|payload| crate::gc::GC_HEADER_SIZE.checked_add(payload)) +} + #[inline] pub(super) unsafe fn array_elements_ptr(arr: *mut ArrayHeader) -> *mut u64 { (arr as *mut u8).add(std::mem::size_of::()) as *mut u64 diff --git a/crates/perry-runtime/src/array/header_gc_slots.rs b/crates/perry-runtime/src/array/header_gc_slots.rs index a56b03cf6b..327a6fffd4 100644 --- a/crates/perry-runtime/src/array/header_gc_slots.rs +++ b/crates/perry-runtime/src/array/header_gc_slots.rs @@ -14,12 +14,30 @@ pub(crate) unsafe fn gc_element_slot_range( } let length = (*arr).length as usize; let capacity = (*arr).capacity as usize; - if length > capacity || length > 16_000_000 { + if capacity > 16_000_000 { return None; } + if length > capacity { + // Preserve the old corruption fail-closed behavior while admitting + // legitimate sparse headers: the claimed capacity must exactly match + // the GC allocation that owns this payload. + let Some(gc_header) = crate::value::addr_class::try_read_tracked_gc_header(arr as usize) + else { + return None; + }; + if checked_array_allocation_size(capacity) != Some((*gc_header.as_ptr()).size as usize) { + return None; + } + } + // A large `new Array(length)` intentionally starts with `length > + // capacity`. Only the allocated dense prefix can contain inline child + // edges; indices beyond it live in the separately-rooted named-property + // table. Returning no range here would lose pointer values written into + // that prefix while it grows (#9371). + let dense_prefix = length.min(capacity); Some(crate::gc::HeapSlotRange::new( array_elements_ptr(arr), - length, + dense_prefix, )) } @@ -248,13 +266,17 @@ pub(crate) unsafe fn replay_array_growth_write_barriers(arr: *mut ArrayHeader) { return; } - let length = (*arr).length as usize; - if length == 0 || length > 16_000_000 { + // `length` may exceed the allocation for a large fresh holey array. Growth + // copies and barriers only the allocated dense prefix; scanning logical + // length here walked beyond intermediate growth allocations and corrupted + // earlier values once the array became dense (#9371). + let dense_prefix = ((*arr).length as usize).min((*arr).capacity as usize); + if dense_prefix == 0 || dense_prefix > 16_000_000 { return; } let slots = array_elements_ptr(arr); - if crate::gc::layout_visit_pointer_slots_for_user(arr as usize, length, |index| { + if crate::gc::layout_visit_pointer_slots_for_user(arr as usize, dense_prefix, |index| { let slot = slots.add(index); crate::gc::runtime_write_barrier_slot(arr as usize, slot as usize, *slot); }) { @@ -265,7 +287,7 @@ pub(crate) unsafe fn replay_array_growth_write_barriers(arr: *mut ArrayHeader) { // per-store entry point would re-derive the parent classification `length` // times and re-assert a page-granular fact ~512 times per page. See // `gc::barrier::replay_old_parent_slot_range`. - crate::gc::replay_old_parent_slot_range_barriers(arr as usize, slots, length); + crate::gc::replay_old_parent_slot_range_barriers(arr as usize, slots, dense_prefix); } #[inline] diff --git a/crates/perry-runtime/src/array/indexing.rs b/crates/perry-runtime/src/array/indexing.rs index 2e6a8590e1..576b67c8d7 100644 --- a/crates/perry-runtime/src/array/indexing.rs +++ b/crates/perry-runtime/src/array/indexing.rs @@ -13,16 +13,6 @@ pub use keyed::{ const MAX_DENSE_ARRAY_GROW_LENGTH: u32 = 1_000_000; -/// Largest hole (`index - length`) an extending write may create while still -/// growing the dense backing store, once the array is past -/// `MAX_DENSE_ARRAY_GROW_LENGTH`. Sparse storage is for *jumps* far beyond the -/// current length (`a[2**32-2] = v` on a 3-element array must not allocate -/// 34 GB); sequential growth (`for (i...) arr[i] = v`, gap 0) must stay dense -/// no matter how large the array gets — routing it through string-keyed -/// property sets is quadratic and hung the 10M-element `03_array_write` -/// benchmark for 6 hours (Regression Check, v0.5.1129–v0.5.1150). -const DENSE_ARRAY_GAP_LIMIT: u32 = 1024; - #[inline] pub(crate) fn invalidate_array_index_fast_path() { PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED.store(1, Ordering::Relaxed); @@ -131,26 +121,6 @@ unsafe fn array_oob_prototype_get(receiver: usize, index: u32) -> f64 { TAG_UNDEFINED_F64 } -#[inline] -unsafe fn array_sparse_index_property_get(arr: *const ArrayHeader, index: u32) -> Option { - let arr = clean_arr_ptr(arr); - if arr.is_null() || index < (*arr).capacity { - return None; - } - let key = index.to_string(); - array_named_property_get_by_name(arr, &key) -} - -unsafe fn array_sparse_index_property_set(arr: *mut ArrayHeader, index: u32, value: f64) { - let key = index.to_string(); - let key_ptr = crate::string::js_string_from_bytes(key.as_ptr(), key.len() as u32); - array_named_property_set(arr, key_ptr, value); - let new_length = index + 1; - if (*arr).length < new_length { - (*arr).length = new_length; - } -} - /// Whether iterating `arr` with the raw dense-store loop would diverge from the /// spec `[[HasProperty]]`/`[[Get]]` protocol. True ("exotic") when the array has /// index accessors / custom-attr descriptors, lives in (partly) sparse storage, @@ -1926,6 +1896,28 @@ unsafe fn js_array_set_f64_extend_resolved( return arr; } if index >= (*arr).capacity { + if blocks_extension { + return arr; + } + let capacity = (*arr).capacity; + // A large pre-sized holey array has logical length but only a + // small dense prefix. Writes at (or near) that prefix are an + // ordinary dense fill, not sparse property creation. Grow + // geometrically just as the extending path does. Once a true + // far index has entered sparse storage, do not grow across it: + // indexed reads below capacity intentionally skip the named- + // property table, so covering it would hide the property. + if index - capacity <= DENSE_ARRAY_GAP_LIMIT + && !array_has_sparse_index_properties_resolved(arr) + { + let arr = js_array_grow(arr, index + 1); + let value = value_handle.get_nanbox_f64(); + let store_flags = array_object_flags_resolved(arr); + // GC_STORE_AUDIT(BARRIERED): the resolved store performs + // the layout note and write barrier on the grown head. + store_array_slot_resolved(arr, index as usize, value, store_flags); + return arr; + } let value = value_handle.get_nanbox_f64(); array_sparse_index_property_set(arr, index, value); return arr; diff --git a/crates/perry-runtime/src/array/indexing_support.rs b/crates/perry-runtime/src/array/indexing_support.rs index 0458146f56..a3836cd490 100644 --- a/crates/perry-runtime/src/array/indexing_support.rs +++ b/crates/perry-runtime/src/array/indexing_support.rs @@ -1,11 +1,44 @@ //! Indexing support split out of `indexing.rs` to keep it under the repo's //! 2000-line cap: the strict-store TypeError throwers, the prototype -//! indexed-property / iterator invalidation latches, and the dense keys-array -//! slot helpers. Pure move except for the `use` lines and `pub(super)` -//! visibility on items `indexing.rs` still calls. +//! indexed-property / iterator invalidation latches, sparse index helpers, and +//! the dense keys-array slot helpers. Pure move except for the `use` lines and +//! `pub(super)` visibility on items `indexing.rs` still calls. use super::*; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +/// Largest hole (`index - length`) an extending write may create while still +/// growing the dense backing store once the array is large. Sparse storage is +/// for jumps far beyond the current length; sequential growth must stay dense +/// because routing it through string-keyed property sets is quadratic. +pub(super) const DENSE_ARRAY_GAP_LIMIT: u32 = 1024; + +#[inline] +pub(super) unsafe fn array_sparse_index_property_get( + arr: *const ArrayHeader, + index: u32, +) -> Option { + let arr = clean_arr_ptr(arr); + if arr.is_null() || index < (*arr).capacity { + return None; + } + let key = index.to_string(); + array_named_property_get_by_name(arr, &key) +} + +pub(super) unsafe fn array_sparse_index_property_set( + arr: *mut ArrayHeader, + index: u32, + value: f64, +) { + let key = index.to_string(); + let key_ptr = crate::string::js_string_from_bytes(key.as_ptr(), key.len() as u32); + array_named_property_set(arr, key_ptr, value); + let new_length = index + 1; + if (*arr).length < new_length { + (*arr).length = new_length; + } +} + /// Resolve a raw array head a generated loop re-read from its root after a /// callback returned: the callback may have grown the array, leaving the root /// on a forwarding stub. Pure `clean_arr_ptr`; null for anything that is not diff --git a/crates/perry-runtime/src/array/large_presized_tests.rs b/crates/perry-runtime/src/array/large_presized_tests.rs new file mode 100644 index 0000000000..a9ca9b3739 --- /dev/null +++ b/crates/perry-runtime/src/array/large_presized_tests.rs @@ -0,0 +1,62 @@ +//! Regression coverage for #9371: a large `new Array(length)` keeps a small +//! initial backing store, but writes at the dense frontier must grow that +//! store instead of accumulating numeric keys in the named-property table. + +use super::*; + +#[test] +fn large_presized_array_grows_its_dense_frontier() { + unsafe { + const LENGTH: u32 = 1_200_000; + let mut arr = js_array_constructor_single(LENGTH as f64); + assert_eq!((*arr).length, LENGTH); + assert_eq!((*arr).capacity, MIN_ARRAY_CAPACITY); + + for index in 0..LENGTH { + let old_capacity = (*arr).capacity; + arr = js_array_set_f64_extend(arr, index, index as f64 + 0.25); + if (*arr).capacity != old_capacity { + assert_eq!( + js_array_get_f64(arr, 0), + 0.25, + "growth at index {index} lost the first value (capacity {old_capacity} -> {})", + (*arr).capacity + ); + assert_eq!( + js_array_get_f64(arr, index), + index as f64 + 0.25, + "growth at index {index} lost the current value" + ); + } + } + + assert_eq!((*arr).length, LENGTH); + assert!( + (*arr).capacity >= LENGTH, + "sequential in-bounds writes must grow dense storage" + ); + for index in 0..LENGTH { + assert_eq!(js_array_get_f64(arr, index), index as f64 + 0.25); + } + } +} + +#[test] +fn existing_sparse_indices_prevent_dense_growth_from_hiding_them() { + unsafe { + let mut arr = js_array_constructor_single(1_000_001.0); + arr = js_array_set_f64_extend(arr, 500_000, 7.0); + let capacity = (*arr).capacity; + assert!(500_000 >= capacity, "fixture must take sparse storage"); + + arr = js_array_set_f64_extend(arr, capacity, 9.0); + + assert_eq!( + (*arr).capacity, + capacity, + "growth must not cover an existing sparse numeric property" + ); + assert_eq!(js_array_get_f64(arr, capacity), 9.0); + assert_eq!(js_array_get_f64(arr, 500_000), 7.0); + } +} diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index af030d5535..eaab9fc272 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -17,6 +17,9 @@ mod indexing; /// 2000-line cap. #[cfg(test)] mod keys_len_cap_tests; +/// #9371: lazy dense growth for large pre-sized holey arrays. +#[cfg(test)] +mod large_presized_tests; /// Test-only strict-dense store helpers, split out of `indexing.rs` for the /// 2000-line cap. #[cfg(test)] @@ -242,19 +245,20 @@ pub(crate) use self::alloc::array_length_from_property_value_or_throw; pub(crate) use self::alloc::{js_array_from_arraylike, js_array_from_string_codepoints}; pub(crate) use self::flat_clone::{dense_spread_copy, dense_spread_source, flattenable_array_ptr}; pub(crate) use self::header::{ - array_byte_size, array_has_named_properties_resolved, array_is_frozen, - array_is_sealed_or_no_extend, array_named_property_delete, array_named_property_delete_by_name, - array_named_property_get, array_named_property_get_by_name, array_named_property_has, - array_named_property_names, array_named_property_set, array_numeric_raw_f64_get, - array_numeric_raw_f64_push_inbounds, array_numeric_raw_f64_set_inbounds, array_object_flags, - array_object_flags_from_tag, array_object_flags_resolved, array_ptr_as_proxy, - array_receiver_addr, array_receiver_gc_tag, buffer_receiver_as_uint8_typed_array, - clean_arr_ptr, clean_arr_ptr_mut, clear_array_numeric_layout, clear_array_numeric_layout_ptr, - gc_element_slot_range, mark_array_layout_unknown, mark_array_raw_f64_holes_fresh, - normalize_array_receiver, note_array_slot, note_array_slot_layout_only, - note_array_slot_resolved_flags, rebuild_array_layout, rebuild_array_layout_exact, - refresh_array_numeric_layout, replay_array_growth_write_barriers, set_array_numeric_layout, - store_array_slot, store_array_slot_resolved, transfer_array_numeric_layout, + array_byte_size, array_has_named_properties_resolved, + array_has_sparse_index_properties_resolved, array_is_frozen, array_is_sealed_or_no_extend, + array_named_property_delete, array_named_property_delete_by_name, array_named_property_get, + array_named_property_get_by_name, array_named_property_has, array_named_property_names, + array_named_property_set, array_numeric_raw_f64_get, array_numeric_raw_f64_push_inbounds, + array_numeric_raw_f64_set_inbounds, array_object_flags, array_object_flags_from_tag, + array_object_flags_resolved, array_ptr_as_proxy, array_receiver_addr, array_receiver_gc_tag, + buffer_receiver_as_uint8_typed_array, clean_arr_ptr, clean_arr_ptr_mut, + clear_array_numeric_layout, clear_array_numeric_layout_ptr, gc_element_slot_range, + mark_array_layout_unknown, mark_array_raw_f64_holes_fresh, normalize_array_receiver, + note_array_slot, note_array_slot_layout_only, note_array_slot_resolved_flags, + rebuild_array_layout, rebuild_array_layout_exact, refresh_array_numeric_layout, + replay_array_growth_write_barriers, set_array_numeric_layout, store_array_slot, + store_array_slot_resolved, transfer_array_named_property_owner, transfer_array_numeric_layout, typed_array_receiver, value_bits_to_number, NumericArrayLayout, MIN_ARRAY_CAPACITY, }; diff --git a/crates/perry-runtime/src/array/push_pop.rs b/crates/perry-runtime/src/array/push_pop.rs index 260b4457ff..36ea2f6610 100644 --- a/crates/perry-runtime/src/array/push_pop.rs +++ b/crates/perry-runtime/src/array/push_pop.rs @@ -203,6 +203,10 @@ pub extern "C" fn js_array_grow(arr: *mut ArrayHeader, min_capacity: u32) -> *mu (new_ptr as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; (*new_header)._reserved = (*old_header)._reserved; crate::gc::layout_transfer(arr as *mut u8, new_ptr as *mut u8); + // Array expandos and sparse numeric indices live in an address-keyed + // side table. Growth is not a collector move, so rekey it explicitly + // before the old address becomes a forwarding stub (#9371). + transfer_array_named_property_owner(arr as usize, new_ptr as usize); // `js_array_grow` is an allocation replacement outside the collector, // so GC's normal side-table rekey phase does not run. Preserve every // accessor/property descriptor already owned by the old array before diff --git a/crates/perry-runtime/src/typed_feedback.rs b/crates/perry-runtime/src/typed_feedback.rs index b5a649c5f2..84ddc168b7 100644 --- a/crates/perry-runtime/src/typed_feedback.rs +++ b/crates/perry-runtime/src/typed_feedback.rs @@ -674,14 +674,24 @@ fn classify_array(addr: usize, index: Option) -> (u32, u16, u64, u16) { ); } - let len = (*(addr as *const ArrayHeader)).length as u64; + let arr = &*(addr as *const ArrayHeader); + let len = arr.length as u64; + // Large pre-sized and truly sparse arrays have logical holes beyond + // their inline allocation. Feedback classifies only physical slots: + // walking or reading through logical `length` would either turn one + // store into an O(length) observation or read past capacity (#9371). + let dense_len = len.min(arr.capacity as u64); let access_kind = match index { Some(i) if i != u32::MAX && i as u64 >= len => ARRAY_ACCESS_INDEXED_OUT_OF_BOUNDS, _ => access_kind, }; - let layout_kind = array_layout_kind(addr, len); - let element_kind = - array_element_kind(addr, index.filter(|i| *i != u32::MAX), len, layout_kind); + let layout_kind = array_layout_kind(addr, dense_len); + let element_kind = array_element_kind( + addr, + index.filter(|i| *i != u32::MAX), + dense_len, + layout_kind, + ); ( 0, gc_type as u16, @@ -1257,7 +1267,12 @@ fn gc_header_for_user_addr(addr: usize) -> Option<*const crate::gc::GcHeader> { }) } -fn plain_array_index_guard(arr: *const ArrayHeader, index: u32, require_in_bounds: bool) -> bool { +fn plain_array_index_guard_impl( + arr: *const ArrayHeader, + index: u32, + require_in_bounds: bool, + allow_sparse_dense_prefix: bool, +) -> bool { let raw_addr = normalize_raw_object_addr(arr as u64); let Some(header) = gc_header_for_user_addr(raw_addr) else { return false; @@ -1297,13 +1312,17 @@ fn plain_array_index_guard(arr: *const ArrayHeader, index: u32, require_in_bound let arr = raw_addr as *const ArrayHeader; let len = (*arr).length; let cap = (*arr).capacity; - if len > 16_000_000 || cap > 16_000_000 || len > cap { + if cap > 16_000_000 || (len > cap && (!allow_sparse_dense_prefix || index >= cap)) { return false; } !require_in_bounds || index < len } } +fn plain_array_index_guard(arr: *const ArrayHeader, index: u32, require_in_bounds: bool) -> bool { + plain_array_index_guard_impl(arr, index, require_in_bounds, false) +} + #[cfg(test)] pub(crate) fn numeric_array_index_guard_for_tests( arr: *const ArrayHeader, @@ -1340,7 +1359,11 @@ fn plain_array_index_set_guard( index: u32, require_in_bounds: bool, ) -> bool { - if !plain_array_index_guard(arr, index, require_in_bounds) { + // #9371: a large pre-sized holey array is allowed to store directly into + // its allocated prefix even while logical `length` exceeds `capacity`. + // The index-specific capacity check keeps every admitted raw store inside + // the allocation; loop/read guards retain the stricter dense-array rule. + if !plain_array_index_guard_impl(arr, index, require_in_bounds, true) { return false; } let raw_addr = normalize_raw_object_addr(arr as u64); @@ -1375,10 +1398,19 @@ fn numeric_array_index_set_guard( return false; }; unsafe { - if (*header)._reserved & crate::gc::GC_ARRAY_RAW_F64_LAYOUT != 0 { + let flags = (*header)._reserved; + let arr = raw_addr as *const ArrayHeader; + if flags & crate::gc::GC_ARRAY_RAW_F64_LAYOUT != 0 { true + } else if (*arr).length > (*arr).capacity { + // The plain set guard proved `index < capacity`. Large fresh holey + // arrays cannot satisfy the dense-layout verifier yet, and that + // verifier reads the logical last slot. Trust the representation's + // raw-f64-or-holes proof instead; a numeric prefix store preserves + // it and remains inside the physical allocation (#9371). + flags & crate::gc::GC_ARRAY_RAW_F64_HOLES != 0 } else { - crate::array::js_array_is_numeric_f64_layout(raw_addr as *const ArrayHeader) != 0 + crate::array::js_array_is_numeric_f64_layout(arr) != 0 } } } diff --git a/crates/perry-runtime/src/typed_feedback/tests.rs b/crates/perry-runtime/src/typed_feedback/tests.rs index aa02479a7d..8bd2cd63f8 100644 --- a/crates/perry-runtime/src/typed_feedback/tests.rs +++ b/crates/perry-runtime/src/typed_feedback/tests.rs @@ -584,6 +584,26 @@ fn typed_feedback_array_set_guards_reject_frozen_arrays() { assert_eq!(snapshot.sites[1].guard_failures, 1); } +#[test] +fn large_presized_array_set_guards_admit_only_the_allocated_prefix() { + let arr = crate::array::js_array_constructor_single(1_000_001.0); + let capacity = unsafe { (*arr).capacity }; + + assert!(plain_array_index_set_guard(arr, 0, true)); + assert!(numeric_array_index_set_guard(arr, 0, true)); + assert!(!plain_array_index_set_guard(arr, capacity, true)); + assert!(!numeric_array_index_set_guard(arr, capacity, true)); + assert!( + !plain_array_index_guard(arr, 0, true), + "read guards must still reject a partially materialized array" + ); + let (_, _, _, boundary_kind) = classify_array(arr as usize, Some(capacity)); + assert_eq!( + boundary_kind, STABLE_VALUE_UNDEFINED, + "feedback classification must not read beyond physical capacity" + ); +} + #[test] fn typed_feedback_array_set_boxed_fallback_preserves_original_index_value() { let _guard = typed_feedback_test_lock(); diff --git a/crates/perry/tests/issue_9371_large_presized_array.rs b/crates/perry/tests/issue_9371_large_presized_array.rs new file mode 100644 index 0000000000..8b7dfefeba --- /dev/null +++ b/crates/perry/tests/issue_9371_large_presized_array.rs @@ -0,0 +1,95 @@ +//! End-to-end regression coverage for #9371. Above one million elements, +//! `new Array(n)` deliberately starts with a small backing store. Sequential +//! indexed writes must materialize dense storage without losing earlier +//! values or falling into quadratic string-keyed property insertion. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +#[test] +fn large_presized_arrays_fill_densely_and_preserve_every_value() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + std::fs::write( + &entry, + r#" +declare function gc(): void; + +function fillAndVerify(slots: number, addExpando: boolean): string { + const values: number[] = new Array(slots); + if (addExpando) (values as any).label = "kept"; + for (let i = 0; i < slots; i++) values[i] = i + 0.25; + if (addExpando) gc(); + + let wrong = 0; + for (let i = 0; i < slots; i++) { + if (values[i] !== i + 0.25) wrong++; + } + return `${slots}:${wrong}:${values[0]}:${values[slots - 1]}:${(values as any).label}`; +} + +console.log(fillAndVerify(900000, false)); +console.log(fillAndVerify(1000001, false)); +console.log(fillAndVerify(1200000, true)); + +interface Cell { value: number } +const cells: Cell[] = new Array(1000001); +for (let i = 0; i < 256; i++) cells[i] = { value: i }; +gc(); +let cellSum = 0; +for (let i = 0; i < 256; i++) cellSum += cells[i].value; +console.log("cells", cells.length, cellSum, cells[0].value, cells[255].value); + +const huge: number[] = new Array(4294967295); +huge[0] = 7; +huge[16] = 9; +huge[100000000] = 11; +console.log(huge.length, huge[0], huge[1] === undefined, huge[16], huge[100000000]); +"#, + ) + .expect("write fixture"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let expected = "900000:0:0.25:899999.25:undefined\n\ + 1000001:0:0.25:1000000.25:undefined\n\ + 1200000:0:0.25:1199999.25:kept\n\ + cells 1000001 32640 0 255\n\ + 4294967295 7 true 9 11\n"; + for moving_gc in [false, true] { + let mut command = Command::new(&output); + if moving_gc { + command + .env("PERRY_GC_FORCE_EVACUATE", "1") + .env("PERRY_GC_VERIFY_EVACUATION", "1"); + } + let run = command.output().expect("run compiled fixture"); + assert!( + run.status.success(), + "compiled fixture failed with moving_gc={moving_gc}\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!(String::from_utf8_lossy(&run.stdout), expected); + } +}