From 66aead9135aa337f98cf8e8e177beb81f11f9419 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 13 Jun 2026 23:52:33 +0200 Subject: [PATCH 1/2] fix(runtime): throw catchable RangeError on over-large allocations (#5067) User-controlled-length constructors aborted the process with `panic!` on allocation failure instead of surfacing a catchable JS error. E.g. `new Uint8Array(1e15)` / `Buffer.alloc(1e15)` killed the process rather than throwing `RangeError: Array buffer allocation failed`. Two distinct root causes: 1. Silent f64->integer truncation hid the over-large request before it ever reached an allocator: - codegen lowered `new Uint8Array(1e15)` / `new Float64Array(1e15)` via the `i32` fast path (`*n as i32`), turning 1e15 into a bogus ~-1.5e9 length; - `js_buffer_validate_size` / `uint8array_length_or_throw` / `typed_array_length_or_throw` accepted lengths up to `2**53-1` (Node's `kMaxLength`) and then saturated the cast to `i32`/`u32`, producing a wrong-size object or routing a multi-GB request into the allocator. 2. The leaf allocators `panic!`'d on a null result. Fixes (the tractable, user-controlled-length paths called out in the issue; the foundational `gc_malloc`/arena allocators are left as-is per the issue): - codegen (`arrays_finds.rs`): only take the `i32` fast path when the literal length actually fits in `i32`; otherwise route to the runtime f64 path which validates the full value. - `typed_array_length_or_throw` / `uint8array_length_or_throw` / `js_buffer_validate_size`: throw `RangeError: Array buffer allocation failed` when the requested length exceeds the representable capacity (`u32`/`i32`) instead of truncating. Matches Node, which passes its `<= 2**53-1` length check for these and then fails the real allocation. - TypedArray / Buffer / Set / Map / RegExp leaf allocators: convert the null-result `panic!` into the same catchable `RangeError` (new shared `error::throw_allocation_failed`). `buffer.constants.MAX_LENGTH` is unchanged (still `2**53-1`). Verified: all four headline cases now throw a catchable RangeError, normal allocations and catch/recover work, and the typed-array unit tests (60 passed) + buffer parity test are unaffected. --- crates/perry-codegen/src/expr/arrays_finds.rs | 13 +++++++++++-- crates/perry-runtime/src/buffer/from.rs | 12 +++++++++++- crates/perry-runtime/src/buffer/header.rs | 14 +++++++++++++- crates/perry-runtime/src/buffer/validate.rs | 13 +++++++++++++ crates/perry-runtime/src/error.rs | 12 ++++++++++++ crates/perry-runtime/src/map.rs | 3 ++- crates/perry-runtime/src/regex.rs | 3 ++- crates/perry-runtime/src/set.rs | 3 ++- crates/perry-runtime/src/typedarray/mod.rs | 14 +++++++++++++- 9 files changed, 79 insertions(+), 8 deletions(-) diff --git a/crates/perry-codegen/src/expr/arrays_finds.rs b/crates/perry-codegen/src/expr/arrays_finds.rs index 5c7a89d669..afee3c55d0 100644 --- a/crates/perry-codegen/src/expr/arrays_finds.rs +++ b/crates/perry-codegen/src/expr/arrays_finds.rs @@ -621,7 +621,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let h = blk.call(I64, "js_uint8array_alloc", &[(I32, "0")]); Ok(nanbox_pointer_inline(blk, &h)) } - Some(Expr::Integer(n)) => { + // Only take the i32 fast path when the literal fits in an i32; + // a larger length (`new Uint8Array(1e15)`) would truncate via + // `*n as i32`, so fall through to the runtime f64 path which + // throws `RangeError: Array buffer allocation failed` (#5067). + Some(Expr::Integer(n)) if *n >= i32::MIN as i64 && *n <= i32::MAX as i64 => { let size_str = (*n as i32).to_string(); let blk = ctx.block(); let h = blk.call(I64, "js_uint8array_alloc", &[(I32, &size_str)]); @@ -911,7 +915,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // Literal integer length: `new Int32Array(3)`. A negative // literal (`new Int32Array(-1)`) is passed through verbatim // so the runtime throws the spec RangeError (#3662). - Expr::Integer(n) => { + // Only take the i32 fast path when the literal actually fits + // in an i32 — otherwise `*n as i32` would silently truncate + // a huge length (`new Uint8Array(1e15)` → a bogus -1.5e9), + // so fall through to the f64 runtime path which throws the + // proper `RangeError: Array buffer allocation failed` (#5067). + Expr::Integer(n) if *n >= i32::MIN as i64 && *n <= i32::MAX as i64 => { let len_str = (*n as i32).to_string(); let p = ctx.block().call( I64, diff --git a/crates/perry-runtime/src/buffer/from.rs b/crates/perry-runtime/src/buffer/from.rs index 3ae93718ee..9d0af6b436 100644 --- a/crates/perry-runtime/src/buffer/from.rs +++ b/crates/perry-runtime/src/buffer/from.rs @@ -484,6 +484,14 @@ fn uint8array_length_or_throw(val: f64) -> u32 { let err = crate::error::js_rangeerror_new(m); crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64)); } + // #5067 — a `Uint8Array` is backed by an `i32`-capacity `BufferHeader`, so + // a length above `i32::MAX` (~2 GiB) cannot be allocated. Node passes the + // `<= 2**53-1` length check for these and then fails the real allocation, + // so match its `RangeError: Array buffer allocation failed` rather than + // silently capping the length (which produced a wrong-size buffer). + if integer > i32::MAX as f64 { + crate::error::throw_allocation_failed(); + } integer as u32 } @@ -571,8 +579,10 @@ pub extern "C" fn js_uint8array_new(val: f64) -> *mut BufferHeader { // Node applies ToIndex (NaN → 0, truncate toward zero) and throws a // RangeError on a negative / out-of-range length (#3662). if !(0x7FFC..=0x7FFF).contains(&top16) { + // `uint8array_length_or_throw` already throws for lengths above + // `i32::MAX` (#5067), so the cast is in range here. let len = uint8array_length_or_throw(val); - return js_uint8array_alloc(len.min(i32::MAX as u32) as i32); + return js_uint8array_alloc(len as i32); } // Any other tag (undefined/null/bool/string/bigint) → empty buffer, // matching the JS semantics of `new Uint8Array(undefined)` et al. diff --git a/crates/perry-runtime/src/buffer/header.rs b/crates/perry-runtime/src/buffer/header.rs index f1a5177f40..6330b2389d 100644 --- a/crates/perry-runtime/src/buffer/header.rs +++ b/crates/perry-runtime/src/buffer/header.rs @@ -3,6 +3,17 @@ use super::*; /// Type ID constant for Buffer/Uint8Array - matches class_id 0xFFFF0004 pub const BUFFER_TYPE_ID: u32 = 0xFFFF0004; +/// #5067 — throw a catchable `RangeError: Array buffer allocation failed` +/// (Node/V8's message) when a buffer backing block cannot be allocated, +/// rather than aborting the process. +#[cold] +fn throw_buffer_alloc_failed() -> ! { + let msg = b"Array buffer allocation failed"; + let s = crate::string::js_string_from_bytes(msg.as_ptr(), msg.len() as u32); + let err = crate::error::js_rangeerror_new(s); + crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64)) +} + /// Buffer header - similar to StringHeader but specifically for binary data /// NOTE: Layout must match ArrayHeader (length at offset 0, capacity at offset 4) /// because the codegen treats Uint8Array like arrays with hardcoded offsets. @@ -531,7 +542,8 @@ pub fn buffer_alloc(capacity: u32) -> *mut BufferHeader { unsafe { let ptr = alloc(layout) as *mut BufferHeader; if ptr.is_null() { - panic!("Failed to allocate buffer"); + // #5067 — surface a catchable `RangeError` instead of aborting. + throw_buffer_alloc_failed(); } (*ptr).length = 0; (*ptr).capacity = capacity; diff --git a/crates/perry-runtime/src/buffer/validate.rs b/crates/perry-runtime/src/buffer/validate.rs index 59a6209908..e87531f771 100644 --- a/crates/perry-runtime/src/buffer/validate.rs +++ b/crates/perry-runtime/src/buffer/validate.rs @@ -88,6 +88,19 @@ pub extern "C" fn js_buffer_validate_size(value: f64) -> i32 { ); crate::fs::validate::throw_range_error_with_code(&msg); } + // #5067 — `size` is in `[0, kMaxLength]` per Node's `assertSize`, but the + // backing buffer capacity is an `i32`, so anything above `i32::MAX` + // (~2 GiB) cannot actually be allocated. Node passes `assertSize` for + // these too and then fails the real allocation, so match its + // `RangeError: Array buffer allocation failed` rather than truncating the + // cast (which produced a wrong-size buffer or aborted in the allocator). + if n > i32::MAX as f64 { + // Plain `RangeError` (no `ERR_*` code) to match V8/Node. + let msg = b"Array buffer allocation failed"; + let s = crate::string::js_string_from_bytes(msg.as_ptr(), msg.len() as u32); + let err = crate::error::js_rangeerror_new(s); + crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64)); + } n as i32 } diff --git a/crates/perry-runtime/src/error.rs b/crates/perry-runtime/src/error.rs index 04df1d385e..98be861bd5 100644 --- a/crates/perry-runtime/src/error.rs +++ b/crates/perry-runtime/src/error.rs @@ -235,6 +235,18 @@ pub extern "C" fn js_rangeerror_new(message: *mut StringHeader) -> *mut ErrorHea unsafe { alloc_error(ERROR_KIND_RANGE_ERROR, b"RangeError", message, true) } } +/// #5067 — throw a catchable `RangeError: Array buffer allocation failed` +/// (V8/Node's allocation-failure message) instead of aborting the process +/// when a user-controlled-size backing buffer cannot be allocated. Shared +/// by the Set/Map/RegExp backing-store allocators. +#[cold] +pub(crate) fn throw_allocation_failed() -> ! { + let msg = b"Array buffer allocation failed"; + let s = crate::string::js_string_from_bytes(msg.as_ptr(), msg.len() as u32); + let err = js_rangeerror_new(s); + crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64)) +} + /// Create a new ReferenceError with a message #[no_mangle] pub extern "C" fn js_referenceerror_new(message: *mut StringHeader) -> *mut ErrorHeader { diff --git a/crates/perry-runtime/src/map.rs b/crates/perry-runtime/src/map.rs index 9d8013ab6d..5ed186e7bb 100644 --- a/crates/perry-runtime/src/map.rs +++ b/crates/perry-runtime/src/map.rs @@ -604,7 +604,8 @@ pub extern "C" fn js_map_alloc(capacity: u32) -> *mut MapHeader { // on a fresh Map that never saw entity 5121. let entries = alloc(ent_layout) as *mut f64; if entries.is_null() { - panic!("Failed to allocate map entries"); + // #5067 — catchable RangeError instead of aborting on OOM. + crate::error::throw_allocation_failed(); } ptr::write_bytes(entries as *mut u8, 0u8, ent_layout.size()); diff --git a/crates/perry-runtime/src/regex.rs b/crates/perry-runtime/src/regex.rs index 989978c384..e03791be06 100644 --- a/crates/perry-runtime/src/regex.rs +++ b/crates/perry-runtime/src/regex.rs @@ -385,7 +385,8 @@ pub extern "C" fn js_regexp_new( unsafe { let raw = crate::gc::gc_malloc(header_size, crate::gc::GC_TYPE_OBJECT); if raw.is_null() { - panic!("Failed to allocate RegExp"); + // #5067 — catchable RangeError instead of aborting on OOM. + crate::error::throw_allocation_failed(); } let ptr = raw as *mut RegExpHeader; // A previous (collected) RegExp at this address may have left expando diff --git a/crates/perry-runtime/src/set.rs b/crates/perry-runtime/src/set.rs index c56030de0c..efcc0cf351 100644 --- a/crates/perry-runtime/src/set.rs +++ b/crates/perry-runtime/src/set.rs @@ -563,7 +563,8 @@ pub extern "C" fn js_set_alloc(capacity: u32) -> *mut SetHeader { ) as *mut SetHeader; let elements = alloc(elem_layout) as *mut f64; if elements.is_null() { - panic!("Failed to allocate set elements"); + // #5067 — catchable RangeError instead of aborting on OOM. + crate::error::throw_allocation_failed(); } // Initialize header diff --git a/crates/perry-runtime/src/typedarray/mod.rs b/crates/perry-runtime/src/typedarray/mod.rs index c7f23fb826..cccf54ed2e 100644 --- a/crates/perry-runtime/src/typedarray/mod.rs +++ b/crates/perry-runtime/src/typedarray/mod.rs @@ -344,6 +344,16 @@ fn typed_array_length_or_throw(val: f64) -> u32 { }; throw_range_error(format!("Invalid typed array length: {shown}").as_bytes()); } + // #5067 — Perry stores the element count in a `u32` capacity field, so a + // length above `u32::MAX` cannot be represented (and the backing block + // could never be allocated anyway). Node passes the `<= 2**53-1` length + // check for these and then fails the actual allocation, so match its + // `RangeError: Array buffer allocation failed` rather than silently + // saturating the cast to `u32::MAX` (which produced a wrong-size array + // or aborted the process in the allocator). + if integer > u32::MAX as f64 { + throw_range_error(b"Array buffer allocation failed"); + } integer as u32 } @@ -532,7 +542,9 @@ pub fn typed_array_alloc(kind: u8, length: u32) -> *mut TypedArrayHeader { unsafe { let raw = alloc(layout); if raw.is_null() { - panic!("typed_array_alloc OOM"); + // #5067 — surface a catchable `RangeError` (Node's + // `Array buffer allocation failed`) instead of aborting. + throw_range_error(b"Array buffer allocation failed"); } let p = raw as *mut TypedArrayHeader; (*p).length = length; From 0ea53d6064b1322ab8705a269743bc1a4a07fa65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 14 Jun 2026 07:03:52 +0200 Subject: [PATCH 2/2] fix(runtime): truncate Buffer size before range check; throw on Map/Set growth OOM (CodeRabbit #5067) --- crates/perry-runtime/src/buffer/validate.rs | 6 +++++- crates/perry-runtime/src/map.rs | 4 +++- crates/perry-runtime/src/set.rs | 4 +++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/perry-runtime/src/buffer/validate.rs b/crates/perry-runtime/src/buffer/validate.rs index e87531f771..9d676422c4 100644 --- a/crates/perry-runtime/src/buffer/validate.rs +++ b/crates/perry-runtime/src/buffer/validate.rs @@ -94,7 +94,11 @@ pub extern "C" fn js_buffer_validate_size(value: f64) -> i32 { // these too and then fails the real allocation, so match its // `RangeError: Array buffer allocation failed` rather than truncating the // cast (which produced a wrong-size buffer or aborted in the allocator). - if n > i32::MAX as f64 { + // + // Compare the *truncated* size: Node truncates a fractional `size` toward + // zero before allocating, so e.g. `Buffer.alloc(2147483647.9)` is the + // valid `i32::MAX`, not an over-range request. + if n.trunc() > i32::MAX as f64 { // Plain `RangeError` (no `ERR_*` code) to match V8/Node. let msg = b"Array buffer allocation failed"; let s = crate::string::js_string_from_bytes(msg.as_ptr(), msg.len() as u32); diff --git a/crates/perry-runtime/src/map.rs b/crates/perry-runtime/src/map.rs index 5ed186e7bb..a624a3ed05 100644 --- a/crates/perry-runtime/src/map.rs +++ b/crates/perry-runtime/src/map.rs @@ -755,7 +755,9 @@ unsafe fn ensure_capacity(map: *mut MapHeader) -> bool { let new_entries = realloc((*map).entries as *mut u8, old_layout, new_layout.size()) as *mut f64; if new_entries.is_null() { - panic!("Failed to grow map entries"); + // #5067 — a constructor-driven `new Map(hugeIterable)` can hit this + // growth path; surface a catchable RangeError instead of aborting. + crate::error::throw_allocation_failed(); } // GC_STORE_AUDIT(INIT): map external buffer pointer moves; live entry slots are dirtied by caller. diff --git a/crates/perry-runtime/src/set.rs b/crates/perry-runtime/src/set.rs index efcc0cf351..52b58dd9b8 100644 --- a/crates/perry-runtime/src/set.rs +++ b/crates/perry-runtime/src/set.rs @@ -541,7 +541,9 @@ unsafe fn ensure_capacity(set: *mut SetHeader) -> bool { let new_elements = realloc((*set).elements as *mut u8, old_layout, new_layout.size()) as *mut f64; if new_elements.is_null() { - panic!("Failed to grow set elements"); + // #5067 — a constructor-driven `new Set(hugeIterable)` can hit this + // growth path; surface a catchable RangeError instead of aborting. + crate::error::throw_allocation_failed(); } // GC_STORE_AUDIT(INIT): set external buffer pointer moves; live slots are dirtied by caller.