Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions changelog.d/7071-array-prototype-addr-relocation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
### Fixed

- **`Array.prototype` hole reads no longer hang after the prototype relocates (#6981).** Reading an index that was never assigned, on a program that has installed an indexed property on `Array.prototype`, could drive the runtime into unbounded recursion and die with `SIGSEGV` on the thread's stack guard page ("Thread stack size exceeded due to excessive recursion"). It was not memory corruption — every dereference on the way down was to valid, mapped memory.

`array::indexing` memoizes `Array.prototype`'s (and `Object.prototype`'s) heap address in a process-global `AtomicUsize`. That is a raw pointer to a **movable** object, and nothing maintained it. Every reader of an array pointer resolves it through `clean_arr_ptr`, which follows `GC_FLAG_FORWARDED` chains; the cache did not. The hole/out-of-bounds read fallback guards against self-recursion with the object-identity test `proto != receiver`, so once the prototype moved, the two sides named the same object by two different addresses, the guard stopped firing, and `js_array_get_f64` ⇄ `array_oob_prototype_get` called each other forever.

Two independent relocations reach it, and only one is the collector:

- **`js_array_grow`** — an indexed write past the dense capacity (`Array.prototype[300] = v`) reallocates the backing store and forwards the old head. **No GC is involved**: this reproduces with `PERRY_GEN_GC=0`, in the shipped configuration, with no environment overrides at all.
- **the copying young-gen minor** — it evacuates the prototype and forwards. This is the form #6981 measured, reachable in the shipped collector under a heap budget.

Fixed with three defences: `array_prototype_addr` / `object_prototype_addr` resolve the forwarding chain and heal the cache in place (covering `js_array_grow`, which the collector never sees, with a call-free not-forwarded fast path); `scan_prototype_addr_cache_roots_mut` is registered in `gc_init` so a relocating cycle rewrites the slot like every other address-holding side table (needed because a swept, recycled from-space stub no longer carries the forwarded bit); and `array_oob_prototype_get` resolves the receiver before the identity compare, making the guard exact by construction.

Same root cause also closes a silent-miss class: before this, a *first* `Array.prototype` index write occurring after a relocation would not set the pollution flag at all, so holes read `undefined` instead of the inherited value.

`scripts/gc_repsel_matrix.sh --arms all --pressure 8` goes from `PASS=325 UNVER=100 XFAIL=1 FAIL=14` to `PASS=339 UNVER=100 XFAIL=1 FAIL=0` over 440 cells (byte-exact vs Node 26.5.0: 425/440 → 439/440), with nothing regressed. All 14 failures were `test_gap_repsel_p4a3_numarray_barriers`, in exactly the 14 arms whose `[gc-copy-minor]` count is non-zero. `evac_minor` and `force_verify` are back in the per-PR arm subset, so "a representation regressed GC correctness under relocation" is now a per-PR signal.
95 changes: 92 additions & 3 deletions crates/perry-runtime/src/array/indexing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,24 @@ fn throw_array_not_extensible_add(index: u32) -> ! {
/// real code nobody adds numeric indices to `Array.prototype`, so the hot OOB
/// path stays a single relaxed atomic load until the (rare) write flips the
/// flag. `usize::MAX` marks the address as not-yet-computed.
///
/// ***THIS IS A RAW ADDRESS OF A MOVABLE OBJECT*** (#6981). `Array.prototype`
/// relocates two different ways, and BOTH leave this cache pointing at a
/// `GC_FLAG_FORWARDED` stub while every reader resolves its own receiver
/// through `clean_arr_ptr` (which follows forwarding):
///
/// 1. `js_array_grow` — an indexed write past the dense capacity
/// (`Array.prototype[300] = v`) reallocates and forwards the old head;
/// 2. the copying young-gen minor — it evacuates the prototype and forwards.
///
/// A stale cache is not merely a wrong value: `array_oob_prototype_get`'s
/// self-recursion guard is `proto != receiver`, and after a move those are two
/// different addresses **for the same object**, so the guard stops firing and
/// `js_array_get_f64` ⇄ `array_oob_prototype_get` recurse until the stack guard
/// page (SIGSEGV, "excessive recursion"). Hence the two defences below:
/// `array_prototype_addr` resolves the forwarding chain and self-heals, and
/// `scan_prototype_addr_cache_roots_mut` lets the collector rewrite the slot so
/// the address stays live even once the from-space stub is recycled.
static ARRAY_PROTO_ADDR: AtomicUsize = AtomicUsize::new(usize::MAX);
static ARRAY_PROTO_HAS_INDEX: AtomicBool = AtomicBool::new(false);

Expand All @@ -72,10 +90,74 @@ pub(crate) fn invalidate_array_index_fast_path() {
PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED.store(1, Ordering::Relaxed);
}

/// GC root scanner for the two memoized prototype addresses (#6981).
///
/// `ARRAY_PROTO_ADDR` / `OBJECT_PROTO_ADDR` hold raw addresses of movable
/// objects, so a relocating cycle must REWRITE them exactly like the other
/// address-holding side tables (`CLASS_PROTOTYPE_OBJECTS`,
/// `TYPED_ARRAY_VIEW_META`, …). Forwarding-chain healing alone is not
/// sufficient: once the from-space stub is swept and its block recycled the
/// `GC_FLAG_FORWARDED` bit is gone, and the cache would then name an unrelated
/// live object. Both intrinsics are reachable from `globalThis`, so the marking
/// half of this visit is redundant; the rewriting half is the point.
pub fn scan_prototype_addr_cache_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) {
for cache in [&ARRAY_PROTO_ADDR, &OBJECT_PROTO_ADDR] {
let cached = cache.load(Ordering::Relaxed);
if cached == usize::MAX || cached == 0 {
continue;
}
let mut addr = cached;
if visitor.visit_usize_slot(&mut addr) {
cache.store(addr, Ordering::Relaxed);
}
}
}

/// Test-only handles on the two memoized prototype addresses, so the #6981
/// regression tests can install a synthetic forwarded stub without touching the
/// realm's real intrinsics.
#[cfg(test)]
pub(crate) fn test_array_proto_addr_cache() -> &'static AtomicUsize {
&ARRAY_PROTO_ADDR
}

#[cfg(test)]
pub(crate) fn test_object_proto_addr_cache() -> &'static AtomicUsize {
&OBJECT_PROTO_ADDR
}

/// Re-read a memoized prototype address through the GC forwarding chain and
/// write the healed address back, so every caller compares (and dereferences)
/// the object's CURRENT location. See the `ARRAY_PROTO_ADDR` doc for why an
/// unresolved cache is a hang, not just a wrong answer (#6981).
///
/// `note_array_index_write` calls this on every indexed array write until the
/// prototype is polluted, so the not-forwarded case must stay call-free: the
/// `try_read_gc_header` probe is `#[inline(always)]` and reduces to two range
/// compares plus one load of a `gc_flags` byte at a fixed, permanently-hot
/// address. It also classifies the address band before dereferencing, so the
/// not-yet-resolved sentinel (`usize::MAX`) and any non-heap value fall
/// straight through.
#[inline]
fn heal_prototype_addr(cache: &AtomicUsize, cached: usize) -> usize {
let forwarded = unsafe {
crate::value::addr_class::try_read_gc_header(cached)
.is_some_and(|header| header.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0)
};
if !forwarded {
return cached;
}
let resolved = crate::value::resolve_forwarding(cached);
if resolved != cached {
cache.store(resolved, Ordering::Relaxed);
}
resolved
}

pub(crate) fn object_prototype_addr() -> usize {
let cached = OBJECT_PROTO_ADDR.load(Ordering::Relaxed);
if cached != usize::MAX {
return cached;
return heal_prototype_addr(&OBJECT_PROTO_ADDR, cached);
}
let ctor = crate::object::js_get_global_this_builtin_value(b"Object".as_ptr(), 6);
let ctor_value = crate::value::JSValue::from_bits(ctor.to_bits());
Expand Down Expand Up @@ -149,7 +231,7 @@ pub(crate) fn array_proto_iterator_modified() -> bool {
pub(crate) fn array_prototype_addr() -> usize {
let cached = ARRAY_PROTO_ADDR.load(Ordering::Relaxed);
if cached != usize::MAX {
return cached;
return heal_prototype_addr(&ARRAY_PROTO_ADDR, cached);
}
let ctor = crate::object::js_get_global_this_builtin_value(b"Array".as_ptr(), 5);
let ctor_value = crate::value::JSValue::from_bits(ctor.to_bits());
Expand Down Expand Up @@ -190,6 +272,13 @@ pub(crate) fn note_array_index_write(arr: usize) {
/// 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);
Expand All @@ -204,7 +293,7 @@ unsafe fn array_oob_prototype_get(receiver: usize, index: u32) -> f64 {
}
if ARRAY_PROTO_HAS_INDEX.load(Ordering::Relaxed) {
let proto = array_prototype_addr();
if proto != 0 && proto != receiver {
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);
Expand Down
3 changes: 3 additions & 0 deletions crates/perry-runtime/src/array/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,10 @@ pub use self::indexing::{
js_array_numeric_set_f64_unboxed, js_array_set_f64, js_array_set_f64_extend,
js_array_set_f64_extend_strict, js_array_set_f64_unchecked, js_array_set_index_or_string,
js_array_set_index_or_string_strict, js_array_set_string_key,
scan_prototype_addr_cache_roots_mut,
};
#[cfg(test)]
pub(crate) use self::indexing::{test_array_proto_addr_cache, test_object_proto_addr_cache};
pub use self::is_array::js_array_is_array;
pub(crate) use self::iter_methods::throw_reduce_of_empty;
pub use self::iter_methods::{
Expand Down
6 changes: 6 additions & 0 deletions crates/perry-runtime/src/gc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,12 @@ pub fn gc_init() {
gc_register_mutable_root_scanner(crate::regex::scan_last_exec_groups_root_mut);
gc_register_mutable_root_scanner(crate::object::scan_exotic_expando_roots_mut);
gc_register_mutable_root_scanner(crate::array::scan_template_raw_roots_mut);
// #6981: the memoized `Array.prototype` / `Object.prototype` addresses in
// `array::indexing`. Raw addresses of movable objects — a relocating cycle
// that does not rewrite them leaves the hole/OOB read fallback comparing a
// stale address against a forwarding-resolved receiver, which defeats its
// own self-recursion guard and drives the mutator into unbounded recursion.
gc_register_mutable_root_scanner(crate::array::scan_prototype_addr_cache_roots_mut);
gc_register_mutable_root_scanner(crate::map::scan_map_iterator_array_roots_mut);
gc_register_mutable_root_scanner(crate::set::scan_set_iterator_array_roots_mut);
gc_register_mutable_root_scanner(crate::perf_hooks::scan_perf_entries_roots_mut);
Expand Down
1 change: 1 addition & 0 deletions crates/perry-runtime/src/gc/tests/runtime_roots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use super::super::*;
use super::support::*;
use std::cell::Cell;
mod callback_scanners;
mod prototype_addr_cache;
mod string_slice;

fn assert_panics_with(expected: &str, f: impl FnOnce()) {
Expand Down
Loading
Loading