diff --git a/changelog.d/9803-module-presized-array-growth.md b/changelog.d/9803-module-presized-array-growth.md new file mode 100644 index 0000000000..d46375cf34 --- /dev/null +++ b/changelog.d/9803-module-presized-array-growth.md @@ -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. diff --git a/crates/perry-codegen/src/collectors/ptr_numarray.rs b/crates/perry-codegen/src/collectors/ptr_numarray.rs index d00e795a5f..fcff52edc5 100644 --- a/crates/perry-codegen/src/collectors/ptr_numarray.rs +++ b/crates/perry-codegen/src/collectors/ptr_numarray.rs @@ -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 @@ -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()` (runtime hole-fills every slot, sets +//! init is `new Array()` 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)` ⇒ @@ -382,6 +384,7 @@ 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 { let value = match e { Expr::Integer(v) => *v as f64, @@ -389,7 +392,11 @@ impl<'a> UseWalk<'a> { 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) @@ -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 diff --git a/crates/perry/tests/issue_9371_large_presized_array.rs b/crates/perry/tests/issue_9371_large_presized_array.rs index 8b7dfefeba..bccc0affe1 100644 --- a/crates/perry/tests/issue_9371_large_presized_array.rs +++ b/crates/perry/tests/issue_9371_large_presized_array.rs @@ -4,12 +4,34 @@ //! 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"); @@ -17,7 +39,9 @@ fn large_presized_arrays_fill_densely_and_preserve_every_value() { 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 { @@ -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"); @@ -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\ @@ -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{}", diff --git a/test-files/test_gap_9784_module_presized_array.ts b/test-files/test_gap_9784_module_presized_array.ts new file mode 100644 index 0000000000..d77930ecc6 --- /dev/null +++ b/test-files/test_gap_9784_module_presized_array.ts @@ -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();