diff --git a/crates/perry-codegen/src/codegen/index_method_clone_tests.rs b/crates/perry-codegen/src/codegen/index_method_clone_tests.rs index 4138a603d6..452a3da5fa 100644 --- a/crates/perry-codegen/src/codegen/index_method_clone_tests.rs +++ b/crates/perry-codegen/src/codegen/index_method_clone_tests.rs @@ -515,6 +515,59 @@ fn checked_reader_callback_loop_versions_to_fast_and_resumable_slow_bodies() { ); } +#[test] +fn versioned_checked_reader_admission_canonicalizes_one_forwarding_edge() { + let ir = emit_versioned_checked_reader_loop(); + let iterate = function_body( + &ir, + "@perry_method_versioned_checked_reader_loop_ts__Reader__iterate(", + ); + let source_guard = iterate + .split("\nversioned_index.array.source_deref.") + .nth(1) + .and_then(|body| body.split("\nversioned_index.array.live_deref.").next()) + .unwrap_or_else(|| panic!("loop has no forwarding-source guard:\n{iterate}")); + let live_handle = source_guard + .lines() + .find(|line| line.contains(" = select i1") && line.contains(", i64 ")) + .and_then(|line| line.trim().split_once(" = ").map(|(name, _)| name)) + .unwrap_or_else(|| panic!("source guard has no selected live handle:\n{source_guard}")); + assert!( + source_guard.contains("and i8") + && source_guard.contains(", 128") + && source_guard.contains("load i64") + && source_guard.contains("label %versioned_index.array.live_deref.") + && source_guard.contains("label %versioned_index.loop.slow.preheader") + && !source_guard.contains(&format!("sub i64 {live_handle}, 8")), + "admission must select one forwarding target and validate its address before \ + reading its header:\n{source_guard}" + ); + let live_guard = iterate + .split("\nversioned_index.array.live_deref.") + .nth(1) + .and_then(|body| body.split("\nversioned_index.array.canonicalize.").next()) + .unwrap_or_else(|| panic!("loop has no selected-target header guard:\n{iterate}")); + assert!( + live_guard.contains(&format!("sub i64 {live_handle}, 8")) + && live_guard.contains("label %versioned_index.array.canonicalize.") + && live_guard.contains("label %versioned_index.loop.slow.preheader"), + "the selected target must be fully re-branded before admission:\n{live_guard}" + ); + let canonicalize = iterate + .split("\nversioned_index.array.canonicalize.") + .nth(1) + .and_then(|body| body.split("\nversioned_index.array.source_deref.").next()) + .unwrap_or_else(|| panic!("loop has no canonicalization block:\n{iterate}")); + assert!( + canonicalize.contains(&format!( + "or i64 {live_handle}, {}", + crate::nanbox::POINTER_TAG_I64 + )) && canonicalize.contains("store ptr addrspace(1)"), + "the uncaptured array local must be rewritten to the admitted live target so \ + iteration guards do not revisit an identity stub:\n{canonicalize}" + ); +} + #[test] fn guarded_read_can_follow_one_forwarding_edge_but_rechecks_the_live_header() { let ir = emit(); diff --git a/crates/perry-codegen/src/stmt/versioned_indexed_loop.rs b/crates/perry-codegen/src/stmt/versioned_indexed_loop.rs index 4332a291ab..f8079d7cf0 100644 --- a/crates/perry-codegen/src/stmt/versioned_indexed_loop.rs +++ b/crates/perry-codegen/src/stmt/versioned_indexed_loop.rs @@ -244,8 +244,12 @@ fn emit_array_admission( slow_label: &str, ) -> Option<(String, String)> { let local_slot = ctx.locals.get(&local_id)?.clone(); - let deref_idx = ctx.new_block("versioned_index.array.deref"); - let deref_label = ctx.block_label(deref_idx); + let source_deref_idx = ctx.new_block("versioned_index.array.source_deref"); + let source_deref_label = ctx.block_label(source_deref_idx); + let live_deref_idx = ctx.new_block("versioned_index.array.live_deref"); + let live_deref_label = ctx.block_label(live_deref_idx); + let canonicalize_idx = ctx.new_block("versioned_index.array.canonicalize"); + let canonicalize_label = ctx.block_label(canonicalize_idx); let heap_floor = crate::target_layout::heap_addr_lower_bound_inclusive(ctx.target_triple).to_string(); let heap_ceiling = @@ -262,10 +266,40 @@ fn emit_array_admission( let below_ceiling = ctx.block().icmp_ult(I64, &array_handle, &heap_ceiling); let in_heap = ctx.block().and(I1, &above_floor, &below_ceiling); let safe = ctx.block().and(I1, &is_pointer, &in_heap); - ctx.block().cond_br(&safe, &deref_label, slow_label); + ctx.block().cond_br(&safe, &source_deref_label, slow_label); - ctx.current_block = deref_idx; - let fingerprint_addr = ctx.block().sub(I64, &array_handle, "8"); + // Array growth leaves a forwarding stub at the identity-bearing address. + // Mirror the ordinary indexed-read guard: follow at most one edge, then + // validate the selected address before touching its header. A longer chain + // remains fail-closed and resumes the generic loop. + ctx.current_block = source_deref_idx; + let source_gc_type_addr = ctx.block().sub(I64, &array_handle, "8"); + let source_gc_type_ptr = ctx.block().inttoptr(I64, &source_gc_type_addr); + let source_gc_type = ctx.block().load(I8, &source_gc_type_ptr); + let source_is_array = ctx.block().icmp_eq(I8, &source_gc_type, "1"); + let source_flags_addr = ctx.block().sub(I64, &array_handle, "7"); + let source_flags_ptr = ctx.block().inttoptr(I64, &source_flags_addr); + let source_flags = ctx.block().load(I8, &source_flags_ptr); + let source_forwarded_bits = ctx.block().and(I8, &source_flags, "128"); + let source_is_forwarded = ctx.block().icmp_ne(I8, &source_forwarded_bits, "0"); + let source_ptr = ctx.block().inttoptr(I64, &array_handle); + let forwarding_target = ctx.block().load(I64, &source_ptr); + let follow_forwarding = ctx.block().and(I1, &source_is_array, &source_is_forwarded); + let live_handle = ctx.block().select( + I1, + &follow_forwarding, + I64, + &forwarding_target, + &array_handle, + ); + let live_above_floor = ctx.block().icmp_uge(I64, &live_handle, &heap_floor); + let live_below_ceiling = ctx.block().icmp_ult(I64, &live_handle, &heap_ceiling); + let live_in_heap = ctx.block().and(I1, &live_above_floor, &live_below_ceiling); + ctx.block() + .cond_br(&live_in_heap, &live_deref_label, slow_label); + + ctx.current_block = live_deref_idx; + let fingerprint_addr = ctx.block().sub(I64, &live_handle, "8"); let fingerprint_ptr = ctx.block().inttoptr(I64, &fingerprint_addr); let fingerprint = ctx.block().load_aligned(I128, &fingerprint_ptr, 8); let gc_header = ctx.block().trunc(I128, &fingerprint, I64); @@ -298,7 +332,17 @@ fn emit_array_admission( pass = ctx.block().and(I1, &pass, &length_sane); pass = ctx.block().and(I1, &pass, &capacity_sane); pass = ctx.block().and(I1, &pass, &length_within_capacity); - ctx.block().cond_br(&pass, success_label, slow_label); + ctx.block().cond_br(&pass, &canonicalize_label, slow_label); + + // Candidate analysis excludes rebinding and closure capture of this local, + // so replacing its internal root with the live address is unobservable. + // It also makes the existing per-iteration fingerprint guard O(1): a later + // growth/GC move turns this live address into a stub and side-exits before + // any effect, instead of re-walking an already-stale identity stub forever. + ctx.current_block = canonicalize_idx; + let live_box = crate::expr::nanbox_pointer_inline(ctx.block(), &live_handle); + ctx.block().store(DOUBLE, &live_box, &local_slot); + ctx.block().br(success_label); Some((local_slot, fingerprint)) } diff --git a/crates/perry/tests/versioned_indexed_loop_forwarding.rs b/crates/perry/tests/versioned_indexed_loop_forwarding.rs new file mode 100644 index 0000000000..e7559d7caf --- /dev/null +++ b/crates/perry/tests/versioned_indexed_loop_forwarding.rs @@ -0,0 +1,108 @@ +//! Runtime regression for forwarded arrays in fallback-free checked-reader +//! loops. Array growth preserves JavaScript identity by leaving a forwarding +//! stub behind; loop admission must normalize one edge to the live array, and +//! a later callback-driven growth must still side-exit before the next effect. + +use std::path::PathBuf; +use std::process::{Command, Output}; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn run_fixture(binary: &std::path::Path, force_evacuation: bool) -> Output { + let mut command = Command::new(binary); + if force_evacuation { + command.env("PERRY_GC_FORCE_EVACUATE", "1"); + } else { + command.env_remove("PERRY_GC_FORCE_EVACUATE"); + } + command + .output() + .expect("run versioned indexed-loop fixture") +} + +#[test] +fn forwarded_arrays_enter_safely_and_callback_growth_resumes_generically() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let binary = dir.path().join("main_bin"); + std::fs::write( + &entry, + r#" +class Reader { + entities: number[] = []; + + private checkedRead(column: any[] | undefined, index: number, type: number): any { + if (column === undefined) throw new Error("missing column " + type); + const value = column[index]; + if (value === 99) throw new Error("missing value " + type + " at " + index); + return value; + } + + iterate( + column: any[], + callback: (entity: number, value: any) => void, + entityFilter?: (entity: number) => boolean, + ): void { + const entities = this.entities; + const entityCount = entities.length; + const cb = callback; + for (let i = 0; i < entityCount; i++) { + const entity = entities[i]!; + if (entityFilter && !entityFilter(entity)) continue; + cb(entity, this.checkedRead(column, i, 1)); + } + } +} + +const reader = new Reader(); +const column: any[] = []; +for (let i = 0; i < 4096; i++) { + reader.entities.push(i); + column.push({ n: i }); +} + +let sum = 0; +let grew = false; +reader.iterate(column, (entity, value) => { + sum += entity + value.n; + if (!grew) { + grew = true; + for (let i = 0; i < 4096; i++) column.push({ n: -1 }); + } +}, undefined); + +console.log(sum + ":" + reader.entities.length + ":" + column.length); +"#, + ) + .expect("write versioned indexed-loop fixture"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&binary) + .arg("--no-cache") + .arg("--no-auto-optimize") + .output() + .expect("compile versioned indexed-loop fixture"); + assert!( + compile.status.success(), + "compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + for force_evacuation in [false, true] { + let run = run_fixture(&binary, force_evacuation); + assert!( + run.status.success(), + "fixture failed (force_evacuation={force_evacuation})\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!(String::from_utf8_lossy(&run.stdout), "16773120:4096:8192\n"); + } +}