Skip to content
Closed
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/9803-module-presized-array-growth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix out-of-bounds numeric array accesses when a static `new Array(n)` length exceeds the runtime’s dense allocation limit. Large module-level fills now use the guarded storage-growth path.
37 changes: 34 additions & 3 deletions crates/perry-codegen/src/collectors/ptr_numarray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
//! as a **numeric-array pointer local** when static analysis proves that for
//! the local's entire lifetime:
//!
//! 1. every element slot in `[0, length)` holds canonical raw-f64 number bits
//! 1. the initial length fits in the allocated backing store, and every
//! element slot in `[0, length)` holds canonical raw-f64 number bits
//! or `TAG_HOLE` (never a NaN-boxed pointer/string/bool/undefined), and
//! 2. `length` never shrinks below the allocation length, and
//! 3. the binding can never go stale (no growth path exists that fails to
Expand All @@ -22,7 +23,8 @@
//! ## Why it is sound (provenance + containment + density)
//!
//! * **Provenance**: the local is initialized by exactly one `Stmt::Let` whose
//! init is `new Array(<static n>)` (runtime hole-fills every slot, sets
//! init is `new Array(<static n>)` within the runtime's fresh dense limit
//! (runtime hole-fills every slot, sets
//! `GC_ARRAY_RAW_F64_HOLES`, and stamps the pointer-free GC layout —
//! `js_array_constructor_single`) or an EMPTY array literal `[]` (length 0;
//! nothing to observe until a numeric push). Density: `new Array(n)` ⇒
Expand Down Expand Up @@ -382,14 +384,19 @@ struct UseWalk<'a> {
impl<'a> UseWalk<'a> {
/// Resolve a static non-negative array-allocation length: an integer
/// literal or a module-level `const` recorded in `compile_time_constants`.
/// The length must also be backed by dense storage at allocation time.
fn static_alloc_length(&self, e: &Expr) -> Option<i64> {
let value = match e {
Expr::Integer(v) => *v as f64,
Expr::Number(v) => *v,
Expr::LocalGet(id) => *self.compile_time_constants.get(id)?,
_ => return None,
};
if !value.is_finite() || value.fract() != 0.0 || !(0.0..=16_000_000.0).contains(&value) {
// #9784: match MAX_FRESH_DENSE_ARRAY_LENGTH in runtime/array/alloc.rs.
// Above this limit `new Array(n)` has logical length n but only a
// small backing store. A length proof cannot justify an unchecked
// slot access there; the guarded tiers must grow/consult storage.
if !value.is_finite() || value.fract() != 0.0 || !(0.0..=1_000_000.0).contains(&value) {
return None;
}
Some(value as i64)
Expand Down Expand Up @@ -1023,6 +1030,30 @@ mod tests {
assert_eq!(fact.proven_initial_length, 0);
}

#[test]
fn fresh_array_proof_requires_allocated_storage_for_the_initial_length() {
for (length, should_promote) in [(1_000_000, true), (1_000_001, false)] {
assert_eq!(is_promoted(&[alloc_let(length)]), should_promote);

// Module-level constants take a separate provenance input from
// literal lengths; both must respect the runtime allocation cap.
let stmts = [let_with(
ARR,
num_array_ty(),
new_array(vec![Expr::LocalGet(OTHER)]),
)];
let collected = collect_num_array_locals(
&stmts,
&HashSet::new(),
&HashMap::new(),
&facts_for(vec![]),
&HashMap::from([(OTHER, length as f64)]),
&HashSet::new(),
);
assert_eq!(collected.contains_key(&ARR), should_promote);
}
}

#[test]
fn promotes_contained_uses() {
// Element read, numeric-valued element write (including a read of the
Expand Down
37 changes: 33 additions & 4 deletions crates/perry/tests/issue_9371_large_presized_array.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,44 @@
//! values or falling into quadratic string-keyed property insertion.

use std::path::PathBuf;
use std::process::Command;
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};

fn perry_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_perry"))
}

fn run_with_timeout(mut command: Command) -> Output {
command.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = command.spawn().expect("run compiled fixture");
let deadline = Instant::now() + Duration::from_secs(30);
loop {
if child.try_wait().expect("poll compiled fixture").is_some() {
return child.wait_with_output().expect("collect fixture output");
}
if Instant::now() >= deadline {
child.kill().expect("kill timed out fixture");
let output = child.wait_with_output().expect("collect timeout output");
panic!(
"large array fixture exceeded 30 seconds\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
std::thread::sleep(Duration::from_millis(25));
}
}

#[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#"
concat!(
include_str!("../../../test-files/test_gap_9784_module_presized_array.ts"),
r#"
declare function gc(): void;

function fillAndVerify(slots: number, addExpando: boolean): string {
Expand Down Expand Up @@ -51,6 +75,7 @@ huge[16] = 9;
huge[100000000] = 11;
console.log(huge.length, huge[0], huge[1] === undefined, huge[16], huge[100000000]);
"#,
),
)
.expect("write fixture");

Expand All @@ -70,7 +95,11 @@ console.log(huge.length, huge[0], huge[1] === undefined, huge[16], huge[10000000
String::from_utf8_lossy(&compile.stderr)
);

let expected = "900000:0:0.25:899999.25:undefined\n\
let expected = "1000000 0 999496507 0 999999\n\
1000001 0 496500 0 1000000\n\
1200000 0 999394967 0 1199999\n\
literal 0 0 1000000\n\
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\
Expand All @@ -82,7 +111,7 @@ console.log(huge.length, huge[0], huge[1] === undefined, huge[16], huge[10000000
.env("PERRY_GC_FORCE_EVACUATE", "1")
.env("PERRY_GC_VERIFY_EVACUATION", "1");
}
let run = command.output().expect("run compiled fixture");
let run = run_with_timeout(command);
assert!(
run.status.success(),
"compiled fixture failed with moving_gc={moving_gc}\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}",
Expand Down
44 changes: 44 additions & 0 deletions test-files/test_gap_9784_module_presized_array.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// #9784: logical array length must not prove backing-store capacity.

const boundarySlots = 1000000;
const boundary: number[] = new Array(boundarySlots);
for (let i = 0; i < boundarySlots; i++) boundary[i] = i;
let boundaryWrong = 0;
let boundaryChecksum = 0;
for (let i = 0; i < boundarySlots; i++) {
if (boundary[i] !== i) boundaryWrong++;
boundaryChecksum = (boundaryChecksum + boundary[i]) % 1000000007;
}
console.log(boundarySlots, boundaryWrong, boundaryChecksum, boundary[0], boundary[boundarySlots - 1]);

const aboveSlots = 1000001;
const above: number[] = new Array(aboveSlots);
for (let i = 0; i < aboveSlots; i++) above[i] = i;
let aboveWrong = 0;
let aboveChecksum = 0;
for (let i = 0; i < aboveSlots; i++) {
if (above[i] !== i) aboveWrong++;
aboveChecksum = (aboveChecksum + above[i]) % 1000000007;
}
console.log(aboveSlots, aboveWrong, aboveChecksum, above[0], above[aboveSlots - 1]);

const largerSlots = 1200000;
const larger: number[] = new Array(largerSlots);
for (let i = 0; i < largerSlots; i++) larger[i] = i;
let largerWrong = 0;
let largerChecksum = 0;
for (let i = 0; i < largerSlots; i++) {
if (larger[i] !== i) largerWrong++;
largerChecksum = (largerChecksum + larger[i]) % 1000000007;
}
console.log(largerSlots, largerWrong, largerChecksum, larger[0], larger[largerSlots - 1]);

// A literal allocation inside a function also supplies a static length proof.
function literalLocal(): void {
const values: number[] = new Array(1000001);
for (let i = 0; i < 1000001; i++) values[i] = i;
let wrong = 0;
for (let i = 0; i < 1000001; i++) if (values[i] !== i) wrong++;
console.log("literal", wrong, values[0], values[1000000]);
}
literalLocal();
Loading