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
1 change: 1 addition & 0 deletions changelog.d/8041-array-growth-forwarding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**Fixed: growing arrays at valid low arena addresses now retain stale-reference forwarding across repeated growth and moving minor GC (#8035).** Array header access now requires allocator ownership before dereferencing, rejects invalid forwarding targets, and installs growth forwarding through a checked raw-header pointer.
175 changes: 50 additions & 125 deletions crates/perry-runtime/src/array/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -537,93 +537,38 @@ pub(crate) fn test_template_raw_roots() -> (usize, usize) {

/// Strip NaN-boxing tags from an array pointer and guard against invalid values.
///
/// Issue #73 follow-up: the `> 0x1000` (4 KB) floor is too permissive
/// for the macOS ARM64 heap layout. A corrupted NaN-box whose 48-bit
/// handle lands in the 1 TB — 2 TB window (e.g. `0x00FF_0000_0000` —
/// a `BufferHeader { length: 0, capacity: 255 }` read as u64) clears
/// the old floor and segfaults `(*arr).length` / SIMD memcpy inside
/// `js_array_slice` / `js_array_length` / etc. Real mimalloc + arena
/// allocations on Darwin consistently land in the 3-5 TB range;
/// constraining to `>= 2 TB && < 128 TB` rejects the observed
/// corruption patterns without cutting off any real heap pointer.
/// Issue #73/#8035 follow-up: address magnitude is not proof of ownership.
/// Corrupted NaN-box payloads can land in a plausible heap window, while real
/// macOS mimalloc arena allocations can land below the former 2 TiB floor.
/// Registry-handle bands are rejected first; GC headers are read only after
/// arena page metadata or the malloc registry proves ownership.
///
/// v0.5.85 follow-up: also validate the GC header byte + length/capacity
/// sanity. A pointer that passes the range check but points into the
/// middle of another allocation (post-GC memory reuse overlaid with
/// e.g. decoded PostgreSQL text column data) reads garbage length
/// values — witnessed `len=775370038 cap=926234674` (both the ASCII
/// bytes of `"6+2.2017"`) flowing through `js_array_slice` and
/// triggering 22GB-wide memcpy segfaults. Post-check: obj_type at
/// `handle-8` must equal GC_TYPE_ARRAY (1), and length must be
/// <= capacity <= 16M (same bound as the GC tracer's sanity guard).
/// triggering 22GB-wide memcpy segfaults. The post-check therefore requires
/// `GC_TYPE_ARRAY` and validates length/capacity before any element access
/// (with the registered Buffer/TypedArray and sparse-array exceptions below).
#[inline(always)]
pub(crate) fn clean_arr_ptr(arr: *const ArrayHeader) -> *const ArrayHeader {
// Heap window varies by allocator and run: macOS mimalloc can land well
// below 2 TB (observed around 45 GB in the Rust test harness);
// Android scudo + Linux glibc also allocate MUCH lower (often < 1 TB); Windows
// mimalloc lands well under 1 TB (often in the GB-to-tens-of-GB range).
// iOS / tvOS / watchOS / visionOS *device* targets use libsystem_malloc
// (mimalloc is host-side only) and allocate in the same low range —
// #1136's `for…of` over `split()` reproed empty because the array
// pointer landed below 2 TB and `clean_arr_ptr` silently null-ed it.
// Using the macOS-tight 2 TB floor on Android / Windows / iOS-family
// silently null-s every real array pointer, turning js_array_set_f64
// into a no-op and — at the read side via js_array_map etc. —
// returning empty arrays for legitimate inputs (issues #385/#386/#387
// for non-macOS hosts; #1136 for iOS device).
//
// The iOS *simulator* runs on the macOS host's mimalloc and lands in
// the 3-5 TB range like macOS itself; lowering the floor to 4 KB does
// not weaken the guard there because the actual liveness check is the
// GcHeader / obj_type validation downstream.
#[cfg(any(
target_os = "android",
target_os = "macos",
target_os = "linux",
target_os = "windows",
target_os = "ios",
target_os = "tvos",
target_os = "watchos",
target_os = "visionos",
))]
const HEAP_MIN: u64 = 0x1000; // 4 KB (classic user-space floor)
#[cfg(not(any(
target_os = "android",
target_os = "macos",
target_os = "linux",
target_os = "windows",
target_os = "ios",
target_os = "tvos",
target_os = "watchos",
target_os = "visionos",
)))]
const HEAP_MIN: u64 = 0x200_0000_0000; // 2 TB — retained for unlisted targets
const HEAP_MAX: u64 = 0x8000_0000_0000; // 47-bit userspace cap
let bits = arr as u64;
let top16 = bits >> 48;
let cleaned = if top16 >= 0x7FF8 {
if top16 == 0x7FFC || (bits & 0x0000_FFFF_FFFF_FFFF) == 0 {
return std::ptr::null();
}
let cleaned_bits = bits & 0x0000_FFFF_FFFF_FFFF;
if !(HEAP_MIN..HEAP_MAX).contains(&cleaned_bits) {
return std::ptr::null();
}
cleaned_bits as *const ArrayHeader
} else {
if !(HEAP_MIN..HEAP_MAX).contains(&bits) {
return std::ptr::null();
}
arr
};
// #5432: reject small-handle ids (fetch/zlib/proxy/common-registry) that
// reached an array helper as the receiver. On non-macOS hosts HEAP_MIN is
// 0x1000 — below the handle band — so a handle like a fetch Headers id
// (0x40000) passes the window check above, and the forwarding-chain /
// obj_type derefs below (`cleaned - 8`) would read unmapped low memory and
// SIGSEGV. Magnitude-classify before any deref and null it (safe
// empty-result no-op), mirroring the addr_class band-map contract.
if crate::value::addr_class::is_handle_band(cleaned as usize) {
// Preserve the permissive window needed by registered Buffer/TypedArray
// receivers, but centralize its platform policy in addr_class. Actual GC
// header reads below require allocator ownership as well.
if !crate::value::addr_class::is_plausible_heap_addr(cleaned as usize) {
return std::ptr::null();
}
// Issue #233: follow GC_FLAG_FORWARDED forwarding chains. When
Expand All @@ -639,19 +584,28 @@ pub(crate) fn clean_arr_ptr(arr: *const ArrayHeader) -> *const ArrayHeader {
// practice (1-2 grows) but cap depth at 64 to defend against
// cycles from corrupted GC state.
let mut cleaned = cleaned;
let mut tracked_header =
unsafe { crate::value::addr_class::try_read_tracked_gc_header(cleaned as usize) };
unsafe {
let mut steps = 0u32;
while (cleaned as usize) >= crate::gc::GC_HEADER_SIZE + 0x1000 {
let gc_header =
(cleaned as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader;
if (*gc_header).gc_flags & crate::gc::GC_FLAG_FORWARDED == 0 {
while let Some(gc_header) = tracked_header {
let gc_header = gc_header.as_ptr();
if (*gc_header).obj_type != crate::gc::GC_TYPE_ARRAY
|| (*gc_header).gc_flags & crate::gc::GC_FLAG_FORWARDED == 0
{
break;
}
let new_user = crate::gc::forwarding_address(gc_header) as u64;
if !(HEAP_MIN..HEAP_MAX).contains(&new_user) {
let new_user = crate::gc::forwarding_address(gc_header) as usize;
let Some(target_header) =
crate::value::addr_class::try_read_tracked_gc_header(new_user)
else {
return std::ptr::null();
};
if (*target_header.as_ptr()).obj_type != crate::gc::GC_TYPE_ARRAY {
return std::ptr::null();
}
cleaned = new_user as *const ArrayHeader;
tracked_header = Some(target_header);
steps += 1;
if steps > 64 {
return std::ptr::null();
Expand All @@ -665,56 +619,33 @@ pub(crate) fn clean_arr_ptr(arr: *const ArrayHeader) -> *const ArrayHeader {
// into a real ArrayHeader and substitute the materialized
// pointer for every downstream accessor. O(1) on subsequent
// calls (idempotent via the `materialized` cache).
unsafe {
if (cleaned as usize) >= crate::gc::GC_HEADER_SIZE + 0x1000 {
let gc_header =
(cleaned as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader;
let obj_type = (*gc_header).obj_type;
if obj_type == crate::gc::GC_TYPE_LAZY_ARRAY {
let addr = cleaned as usize;
let tracked_obj_type =
tracked_header.map(|gc_header| unsafe { (*gc_header.as_ptr()).obj_type });
if let Some(obj_type) = tracked_obj_type {
if obj_type == crate::gc::GC_TYPE_LAZY_ARRAY {
unsafe {
let lazy = cleaned as *mut crate::json_tape::LazyArrayHeader;
if (*lazy).magic == crate::json_tape::LAZY_ARRAY_MAGIC {
let materialized = crate::json_tape::force_materialize_lazy(lazy);
return materialized as *const ArrayHeader;
}
}
// #7574: a `GC_TYPE_OBJECT` / `GC_TYPE_CLOSURE` allocation is NOT
// an `ArrayHeader`, and the two layouts overlay field for field —
// `ArrayHeader.length` reads `ObjectHeader.object_type` (= 1),
// `.capacity` reads `class_id`, and the element slots at +8/+16/+24
// are `parent_class_id ‖ field_count`, `keys_array` and `meta`. The
// sanity check below waves that through (1 <= class_id <= 100M), so
// an element WRITE overwrites two live GC child edges with
// arbitrary doubles and the collector then traces them: `class X
// extends Array` in a `T[]`-annotated binding SIGSEGVs on its
// second `.push()`.
//
// A declared TypeScript type is a hint, never a layout fact
// (CLAUDE.md, *Known Limitations*), so this is reachable from every
// binding form. Refusing it here makes ALL ~190 `clean_arr_ptr`
// call sites fail-closed at once — each degrades through its
// existing null branch instead of dereferencing a forged header —
// and it is the same "resolve at the shared runtime funnel, not at
// one codegen predicate at a time" shape #7573 used for Map/Set.
//
// Correctness (rather than mere safety) for the entry points the
// declared-type tiers actually reach is layered on top: those null
// branches re-enter through `array::subclass::array_object_*`,
// which runs the operation on the spec-generic array-like engine.
//
// Costs one compare on a byte this block already loaded. Buffers
// and typed arrays are `std::alloc`-backed with no `GcHeader`, so
// their preceding bytes are allocator bookkeeping that can read as
// any value — confirm against the registries before nulling, in the
// cold arm only.
if obj_type == crate::gc::GC_TYPE_OBJECT || obj_type == crate::gc::GC_TYPE_CLOSURE {
let addr = cleaned as usize;
if !crate::buffer::is_registered_buffer(addr)
&& crate::typedarray::lookup_typed_array_kind(addr).is_none()
{
return std::ptr::null();
}
}
}
// A declared TypeScript type is a hint, never a layout fact. Reject
// every tracked non-array at this shared funnel before treating its
// payload as an ArrayHeader (#7574).
if obj_type != crate::gc::GC_TYPE_ARRAY {
return std::ptr::null();
}
} else if !crate::buffer::is_registered_buffer(addr)
&& crate::typedarray::lookup_typed_array_kind(addr).is_none()
{
// Handles, synthetic pointers, and unrelated allocations must be
// rejected before any GcHeader or ArrayHeader dereference. Registered
// Buffer/TypedArray receivers intentionally use the compatible
// length/capacity prefix and carry no GcHeader.
return std::ptr::null();
}
// Length/capacity sanity: dense arrays have length <= capacity and
// length below 100M (800 MB of element payload — well above legitimate
Expand All @@ -732,15 +663,9 @@ 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;
let sparse_array_shape = if addr >= crate::gc::GC_HEADER_SIZE + 0x1000 {
let gc_header = (cleaned as *const u8).sub(crate::gc::GC_HEADER_SIZE)
as *const crate::gc::GcHeader;
(*gc_header).obj_type == crate::gc::GC_TYPE_ARRAY
&& hdr.length > hdr.capacity
&& hdr.capacity <= 1_000_000
} else {
false
};
let sparse_array_shape = tracked_obj_type == Some(crate::gc::GC_TYPE_ARRAY)
&& hdr.length > hdr.capacity
&& hdr.capacity <= 1_000_000;
if sparse_array_shape {
return cleaned;
}
Expand Down
116 changes: 41 additions & 75 deletions crates/perry-runtime/src/array/push_pop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,33 @@ fn throw_non_writable_length() -> ! {
);
}

/// Install an array-growth forwarding stub after `tracked_header_for` proves
/// allocator ownership of `old_user_addr`. Keeping the classifier injectable
/// makes the below-2-TiB macOS case deterministic without mapping a fixed low
/// virtual address in the test process.
///
/// # Safety
/// The classifier must return the live header for `old_user_addr`, and
/// `new_user_addr` must be a live array allocation.
#[inline]
pub(super) unsafe fn install_array_growth_forwarding_with(
old_user_addr: usize,
new_user_addr: *mut u8,
tracked_header_for: impl FnOnce(usize) -> Option<std::ptr::NonNull<crate::gc::GcHeader>>,
) -> bool {
let Some(header) = tracked_header_for(old_user_addr) else {
return false;
};
let header = header.as_ptr();
if (*header).obj_type != crate::gc::GC_TYPE_ARRAY
|| (*header).gc_flags & crate::gc::GC_FLAG_ARENA == 0
{
return false;
}
crate::gc::set_forwarding_address(header, new_user_addr);
true
}

#[inline]
pub(crate) fn guard_writable_length(arr: *const ArrayHeader) {
if array_length_is_non_writable(arr) {
Expand Down Expand Up @@ -135,46 +162,20 @@ pub extern "C" fn js_array_grow(arr: *mut ArrayHeader, min_capacity: u32) -> *mu
// ptr. Unlike GC-evacuation originals, array-growth stubs stay
// retained because stale array references rely on clean_arr_ptr
// following this chain.
// Only valid for arena-allocated arrays (which have a GcHeader
// 8 bytes before the user pointer); guard with a heap-bounds
// check that mirrors clean_arr_ptr's HEAP_MIN to skip pointers
// that don't have a real GcHeader behind them (e.g. test-mode
// synthetic pointers, longlived-arena edge cases).
// #1136: iOS family device allocates via libsystem_malloc in the
// same low range as Android/Linux; mirror `clean_arr_ptr`'s
// platform split so growth forwarding can install a stub for
// arrays that live below 2 TB.
#[cfg(any(
target_os = "android",
target_os = "linux",
target_os = "windows",
target_os = "ios",
target_os = "tvos",
target_os = "watchos",
target_os = "visionos",
))]
const HEAP_MIN: usize = 0x1000;
#[cfg(not(any(
target_os = "android",
target_os = "linux",
target_os = "windows",
target_os = "ios",
target_os = "tvos",
target_os = "watchos",
target_os = "visionos",
)))]
const HEAP_MIN: usize = 0x200_0000_0000;
if (arr as usize) >= HEAP_MIN + crate::gc::GC_HEADER_SIZE {
// Only forward arrays that came from the GC arena. A
// non-array obj_type would mean something has gone wrong
// upstream; bail out without forwarding rather than corrupt
// an unrelated allocation's header.
if (*old_header).obj_type == crate::gc::GC_TYPE_ARRAY {
crate::gc::set_forwarding_address(old_header, new_ptr as *mut u8);
}
} else {
report_growth_stub_skipped_below_heap_min(arr as usize);
}
// Ownership comes from the same canonical arena/malloc classifier
// that clean_arr_ptr uses while following this stub. In particular,
// valid low-address macOS arena allocations are accepted, while
// handles, synthetic pointers, and unrelated allocations are rejected
// before a header dereference.
let installed =
install_array_growth_forwarding_with(arr as usize, new_ptr as *mut u8, |addr| {
crate::value::addr_class::try_read_tracked_gc_header(addr)
});
assert!(
installed,
"array growth could not install a forwarding stub for the tracked source at {:#x}",
arr as usize
);

new_ptr
}
Expand Down Expand Up @@ -1109,38 +1110,3 @@ pub extern "C" fn js_array_unshift_variadic(
#[used]
static KEEP_UNSHIFT_VARIADIC: extern "C" fn(*mut ArrayHeader, *const f64, u32) -> *mut ArrayHeader =
js_array_unshift_variadic;

/// The `HEAP_MIN` guard above is an **address-conditional silent divergence**:
/// whether a growth forwarding stub is installed depends on where the allocator
/// happened to place the array. Below the floor the stub is skipped, so a stale
/// pre-grow reference stops resolving (issue #233's whole mechanism) — with no
/// signal at all.
///
/// That silence has already cost real debugging time. While investigating
/// #7022 an experiment replaced arena blocks with `mmap`'d, guard-paged blocks;
/// `mmap` with a NULL hint lands well below macOS's 2 TB floor, so this branch
/// silently disabled every growth stub and the experiment came back **falsely
/// clean**. It was only caught by re-running with a high `MAP_FIXED` hint.
///
/// Emit once per process so the next person gets a signal instead of a silent
/// behaviour change. One line on stderr, so it cannot perturb a stdout parity
/// comparison.
#[cold]
fn report_growth_stub_skipped_below_heap_min(arr_addr: usize) {
use std::sync::atomic::{AtomicBool, Ordering};
static REPORTED: AtomicBool = AtomicBool::new(false);
debug_assert!(
false,
"array-growth forwarding stub skipped: array at {arr_addr:#x} is below the platform heap floor"
);
if REPORTED.swap(true, Ordering::Relaxed) {
return;
}
eprintln!(
"[perry-gc] array-growth forwarding stub SKIPPED for an array at {arr_addr:#x}: \
address is below this platform's heap floor. Stale pre-grow array references \
will no longer resolve through the growth chain (issue #233). This is normally \
unreachable; it usually means the arena is being backed by an allocator that \
places blocks outside the expected range. Reported once per process."
);
}
Loading
Loading