diff --git a/changelog.d/8839-guarded-ecs-entity-index-cache.md b/changelog.d/8839-guarded-ecs-entity-index-cache.md new file mode 100644 index 0000000000..3b01d6ba94 --- /dev/null +++ b/changelog.d/8839-guarded-ecs-entity-index-cache.md @@ -0,0 +1,6 @@ +Nested stable-packed ECS loops now reuse an admitted receiver proof on call-free paths and cache +repeated reads of the same entity index. Semantic calls invalidate both facts before execution; the +next indexed access reloads the rooted receiver and revalidates it, with an exact generic read on +failure rather than replaying prior iteration effects. This preserves getters, proxies, exceptions, +mutation, and moving-GC behavior while making the unchanged Wolf `simple_iter` kernel 8.82% faster +in an 11-pair controlled cohort (11/11 wins and 30/30 semantic-oracle passes). diff --git a/crates/perry-codegen/src/block.rs b/crates/perry-codegen/src/block.rs index be7ad0c5f7..be659c1264 100644 --- a/crates/perry-codegen/src/block.rs +++ b/crates/perry-codegen/src/block.rs @@ -92,6 +92,18 @@ pub struct RegCounter { /// callee is UB, so the registry, not the emitting code, is the single /// source of truth. `None` for functions built outside a module (tests). preserve_none_fns: RefCell>>>>, + /// Compiler-private validity bits for nested stable-packed loop proofs. + /// + /// A guarded inner receiver may keep its raw address across call-free + /// direct-load/store arms. Every actually executed runtime/indirect call + /// that can collect or run semantic heap work dirties the active proofs + /// before control can enter the callee; the next indexed read then reloads + /// its GC root and revalidates. Root bookkeeping and write barriers are + /// proof-preserving: they neither collect nor change JS-visible receiver + /// state. Keeping this at the call-emission choke point makes invalidation + /// path-sensitive: cold IC misses dirty the proof, while their unexecuted + /// hot siblings do not impose a revalidation on every read. + stable_packed_revalidation_slots: RefCell>, } impl RegCounter { @@ -101,9 +113,29 @@ impl RegCounter { eh_unwind_labels: RefCell::new(Vec::new()), shadow_slot_allocas: RefCell::new(HashSet::new()), preserve_none_fns: RefCell::new(None), + stable_packed_revalidation_slots: RefCell::new(Vec::new()), } } + pub(crate) fn push_stable_packed_revalidation_slot(&self, slot: String) { + self.stable_packed_revalidation_slots + .borrow_mut() + .push(slot); + } + + pub(crate) fn pop_stable_packed_revalidation_slot(&self, expected: &str) { + let actual = self + .stable_packed_revalidation_slots + .borrow_mut() + .pop() + .expect("stable-packed revalidation slot stack underflow"); + debug_assert_eq!(actual, expected); + } + + fn stable_packed_revalidation_slots(&self) -> Vec { + self.stable_packed_revalidation_slots.borrow().clone() + } + /// Install the module's `preserve_nonecc` symbol registry (#8175). Called /// once per function by `LlModule::define_function`; the shared cell means /// registration order does not matter — reads happen at call-emission and @@ -278,6 +310,26 @@ impl LlBlock { format!("%r{}", self.counter.next()) } + /// Invalidate every nested packed receiver whose live raw address may be + /// observed after this call. Intrinsics cannot enter Perry or user code. + /// Shadow-stack operations and write barriers are also safe: both families + /// are noncollecting GC bookkeeping and cannot mutate the guarded object's + /// JS-visible shape, prototype, length, or indexed values. Every other + /// direct call stays conservative, including unknown GC-leaf helpers that + /// may perform a semantic write without collecting. + fn dirty_stable_packed_revalidations_before_call(&mut self, direct_callee: Option<&str>) { + if direct_callee.is_some_and(|callee| { + callee.starts_with("llvm.") + || callee.starts_with("js_shadow_") + || callee.starts_with("js_write_barrier") + }) { + return; + } + for slot in self.counter.stable_packed_revalidation_slots() { + self.store(crate::types::I1, "1", &slot); + } + } + pub fn next_reg(&self) -> String { self.reg() } @@ -1245,6 +1297,7 @@ impl LlBlock { args: &[(LlvmType, &str)], gc_leaf: bool, ) -> String { + self.dirty_stable_packed_revalidations_before_call(Some(func_name)); // #835 + #846: record this emission against the FFI provenance // registry. The driver consults the registry after all per-module // codegen finishes to auto-link the providing crate. @@ -1285,6 +1338,7 @@ impl LlBlock { } pub fn call_void(&mut self, func_name: &str, args: &[(LlvmType, &str)]) { + self.dirty_stable_packed_revalidations_before_call(Some(func_name)); // #835 + #846: same registry hook as `call` — see comment there. crate::ext_registry::record_ffi_call(func_name); self.counter @@ -1353,6 +1407,7 @@ impl LlBlock { args: &[(LlvmType, &str)], gc_leaf: bool, ) -> String { + self.dirty_stable_packed_revalidations_before_call(None); let r = self.reg(); // Indirect targets (closures, method pointers) can always throw. if let Some(lpad) = self.counter.current_eh_unwind_label() { @@ -1492,7 +1547,7 @@ fn format_args(args: &[(LlvmType, &str)]) -> String { #[cfg(test)] mod tests { use super::*; - use crate::types::{DOUBLE, I64}; + use crate::types::{DOUBLE, I64, PTR}; use std::thread; fn fresh() -> LlBlock { @@ -1611,6 +1666,38 @@ mod tests { .contains("call double @js_nanbox_string(i64 %handle)")); } + #[test] + fn active_stable_packed_proofs_are_dirtied_only_by_executed_non_intrinsic_calls() { + let mut b = fresh(); + b.counter + .push_stable_packed_revalidation_slot("%proof_dirty".to_string()); + b.call(DOUBLE, "llvm.fabs.f64", &[(DOUBLE, "%value")]); + b.call_void("js_shadow_slot_bind", &[(I64, "0"), (PTR, "%root")]); + b.call_void("js_write_barrier_root_nanbox", &[(I64, "%bits")]); + b.call(DOUBLE, "js_dyn_index_get", &[(DOUBLE, "%object")]); + b.call_indirect(DOUBLE, "%callback", &[(DOUBLE, "%value")]); + b.counter + .pop_stable_packed_revalidation_slot("%proof_dirty"); + b.call(DOUBLE, "js_dyn_index_get", &[(DOUBLE, "%object")]); + + let ir = b.to_ir(); + assert_eq!( + ir.matches("store i1 1, ptr %proof_dirty").count(), + 2, + "{ir}" + ); + assert!( + ir.find("call double @llvm.fabs.f64") < ir.find("store i1 1, ptr %proof_dirty"), + "proof-preserving calls must not dirty the proof: {ir}" + ); + let first_dirty = ir.find("store i1 1, ptr %proof_dirty").unwrap(); + assert!( + ir.find("@js_shadow_slot_bind").unwrap() < first_dirty + && ir.find("@js_write_barrier_root_nanbox").unwrap() < first_dirty, + "GC bookkeeping must preserve the proof: {ir}" + ); + } + #[test] fn direct_gc_leaf_call_places_the_callsite_attribute_after_arguments() { let mut b = fresh(); diff --git a/crates/perry-codegen/src/expr/i32_fast_path.rs b/crates/perry-codegen/src/expr/i32_fast_path.rs index 0b498d85be..7562f25ac5 100644 --- a/crates/perry-codegen/src/expr/i32_fast_path.rs +++ b/crates/perry-codegen/src/expr/i32_fast_path.rs @@ -1588,6 +1588,11 @@ fn lower_expr_native_u32(ctx: &mut FnCtx<'_>, e: &Expr) -> Result if let Some(lowered) = lower_packed_u32_loop_index_get(ctx, e)? { return Ok(lowered); } + if let Expr::IndexGet { object, index } = e { + if let Some(lowered) = super::try_lower_proven_view_checked_u32_load(ctx, object, index)? { + return Ok(lowered); + } + } if let Some(lowered) = crate::expr::lower_expr_value(ctx, e)? { let value = match lowered.rep { NativeRep::I32 | NativeRep::U32 | NativeRep::BufferLen => Some(lowered.value), diff --git a/crates/perry-codegen/src/expr/index_get.rs b/crates/perry-codegen/src/expr/index_get.rs index 39b96265e9..5c007fa0f0 100644 --- a/crates/perry-codegen/src/expr/index_get.rs +++ b/crates/perry-codegen/src/expr/index_get.rs @@ -71,6 +71,9 @@ use inline_dyn_typed_array::lower_inline_dyn_typed_array_get; /// object: a type-confused, `unbox`ed-pointer-plus-wrong-offset write, /// not merely a missed optimization. fn is_width_tracked_typed_array_receiver(ctx: &FnCtx<'_>, object: &Expr) -> bool { + if matches!(object, Expr::LocalGet(id) if ctx.buffer_view_slots.contains_key(id)) { + return true; + } // This predicate selects only runtime-validated typed-array helpers (or a // `buffer_view_slots` proof that invalidates on assignment), as documented // above. Preserve the declared kind as a hint for that dynamic fallback; diff --git a/crates/perry-codegen/src/expr/index_set.rs b/crates/perry-codegen/src/expr/index_set.rs index 86c1b256de..ec4d963473 100644 --- a/crates/perry-codegen/src/expr/index_set.rs +++ b/crates/perry-codegen/src/expr/index_set.rs @@ -39,6 +39,9 @@ use crate::rooting; use crate::type_analysis::{is_array_expr, is_numeric_expr, is_string_expr, receiver_class_name}; use crate::types::{DOUBLE, I32, I64}; +use super::index_set_packed_loop::{ + lower_packed_f64_range_loop_index_set, lower_packed_numeric_loop_index_set, +}; use super::index_set_typed_array::lower_inline_dyn_typed_array_set; use super::{ array_kind_fact, array_store_needs_layout_note, array_store_needs_write_barrier, @@ -52,7 +55,7 @@ use super::{ TypedFeedbackContract, TypedFeedbackKind, }; -fn canonicalize_raw_f64_numeric_store_value( +pub(super) fn canonicalize_raw_f64_numeric_store_value( blk: &mut crate::block::LlBlock, value_double: &str, ) -> String { @@ -128,6 +131,9 @@ fn lower_value_for_dynamic_index_set( /// typed-array object (data at byte 16): a type-confused write, not a missed /// optimization. fn is_width_tracked_typed_array_receiver(ctx: &FnCtx<'_>, object: &Expr) -> bool { + if matches!(object, Expr::LocalGet(id) if ctx.buffer_view_slots.contains_key(id)) { + return true; + } let ty = match object { Expr::LocalGet(id) => ctx.local_type_hint(id).cloned(), _ => crate::type_analysis::static_type_of(ctx, object), @@ -195,7 +201,11 @@ fn bitand_has_nonnegative_i32_mask(left: &Expr, right: &Expr) -> bool { .is_some_and(|mask| (0..=i32::MAX as i64).contains(&mask)) } -fn packed_f64_loop_fact(ctx: &FnCtx<'_>, arr_id: u32, idx_id: u32) -> Option { +pub(super) fn packed_f64_loop_fact( + ctx: &FnCtx<'_>, + arr_id: u32, + idx_id: u32, +) -> Option { ctx.packed_f64_loop_facts .iter() .find(|fact| fact.array_local_id == arr_id && fact.index_local_id == idx_id) @@ -343,405 +353,6 @@ fn lower_array_index_set_via_runtime_key( ) } -fn lower_packed_f64_loop_store_value( - ctx: &mut FnCtx<'_>, - arr_id: u32, - value: &Expr, -) -> Result<(String, Vec)> { - if let Expr::MathAbs(operand) = value { - // Only fold to `llvm.fabs.f64` when the inner read is a PROVEN packed-f64 - // load (same array, index is the packed-loop counter). A general - // `arr[key]` can lower through the boxed/runtime fallback to a NaN-boxed - // JS value, and `fabs` (a bare sign-bit clear) would skip `Math.abs`'s - // ToNumber coercion on it. - if let Expr::IndexGet { object, index } = operand.as_ref() { - let proven_packed_load = matches!(object.as_ref(), Expr::LocalGet(id) if *id == arr_id) - && matches!(index.as_ref(), Expr::LocalGet(idx_id) - if packed_f64_loop_fact(ctx, arr_id, *idx_id).is_some()); - if proven_packed_load { - let raw = lower_expr(ctx, operand)?; - let abs = ctx.block().call(DOUBLE, "llvm.fabs.f64", &[(DOUBLE, &raw)]); - return Ok((abs, vec!["rhs_unary_math=llvm.fabs.f64".to_string()])); - } - } - } - Ok((lower_expr(ctx, value)?, Vec::new())) -} - -fn lower_packed_numeric_loop_store_value( - ctx: &mut FnCtx<'_>, - arr_id: u32, - value: &Expr, - array_kind: PackedNumericLoopKind, -) -> Result<(String, String, Vec)> { - match array_kind { - PackedNumericLoopKind::F64 => { - let (value, notes) = lower_packed_f64_loop_store_value(ctx, arr_id, value)?; - Ok((value.clone(), value, notes)) - } - PackedNumericLoopKind::I32 => { - let value_i32 = lower_expr_as_i32(ctx, value)?; - let value_double = ctx.block().sitofp(I32, &value_i32, DOUBLE); - Ok(( - value_double, - value_i32, - vec!["rhs_i32_store=sitofp_i32_to_raw_f64_slot".to_string()], - )) - } - PackedNumericLoopKind::U32 => { - // No packed-U32 store fast path exists yet, and the IndexSet caller - // already routes U32 facts to the generic array-store path (see the - // `!matches!(.., U32)` guard below). This arm is therefore - // unreachable in practice; rather than `bail!` (a whole-compile - // failure) if a future change ever routes a U32 store here, degrade - // to the F64 full-value store. A uint32 is representable exactly in - // f64, so storing the full value is always correct — just not the - // (nonexistent) packed-U32 fast path. See #5464. - let (value, notes) = lower_packed_f64_loop_store_value(ctx, arr_id, value)?; - Ok((value.clone(), value, notes)) - } - } -} - -/// #6011: inline store for the hole-tolerant *range-guarded* packed-f64 loop. -/// -/// The range guard already proved at loop entry that every index this loop -/// can touch is in bounds, that the receiver is a plain, mutable (not -/// frozen/sealed), descriptor-free array, and that its slots are raw-f64 -/// numbers or `TAG_HOLE` — and the matcher proved the body cannot invalidate -/// any of that mid-loop (no calls/closures/awaits, stores only through this -/// path). The only per-iteration check left is on the RHS *value*: a NaN-boxed -/// non-double (string/object/undefined/INT32-boxed int/…) side-exits to the -/// slow loop, which re-executes the current iteration through the generic -/// store (the side exit fires before the store, so nothing double-applies). -/// The store itself is a raw f64 write; overwriting `TAG_HOLE` with a number -/// is exactly JS element definition on an in-bounds index, and a number never -/// carries a heap edge, so no barrier / layout note is needed (the guard -/// (re)asserted the pointer-free GC layout). -fn lower_packed_f64_range_loop_index_set( - ctx: &mut FnCtx<'_>, - arr_id: u32, - idx_i32: &str, - value: &Expr, - guard_id: &str, - side_exit_label: &str, -) -> Result { - let (val_double, rhs_notes) = lower_packed_f64_loop_store_value(ctx, arr_id, value)?; - - let fast_idx = ctx.new_block("packed_f64_range_store.fast"); - let exit_idx = ctx.new_block("packed_f64_range_store.side_exit"); - let fast_label = ctx.block_label(fast_idx); - let exit_label = ctx.block_label(exit_idx); - - // Numeric-bits check: (bits >> 48) - 0x7FF9 , - arr_id: u32, - idx_i32: &str, - value: &Expr, - guard_id: &str, - side_exit_label: &str, - array_kind: PackedNumericLoopKind, - allow_holes: bool, -) -> Result { - if allow_holes && matches!(array_kind, PackedNumericLoopKind::F64) { - return lower_packed_f64_range_loop_index_set( - ctx, - arr_id, - idx_i32, - value, - guard_id, - side_exit_label, - ); - } - let (val_double, native_value, rhs_notes) = - lower_packed_numeric_loop_store_value(ctx, arr_id, value, array_kind)?; - let arr_expr = Expr::LocalGet(arr_id); - let arr_box = lower_expr(ctx, &arr_expr)?; - let feedback_site_id = emit_typed_feedback_register_site( - ctx, - TypedFeedbackKind::ArrayElement, - match array_kind { - PackedNumericLoopKind::F64 => "array[packed_f64_loop]=", - PackedNumericLoopKind::I32 => "array[packed_i32_loop]=", - PackedNumericLoopKind::U32 => "array[packed_u32_loop]=", - }, - TypedFeedbackContract::bounded_numeric_array_set_index(), - ); - let loop_label = array_kind.loop_label(); - let fast_idx = ctx.new_block(&format!("{loop_label}_loop_store.fast")); - let fallback_idx = ctx.new_block(&format!("{loop_label}_loop_store.fallback")); - let merge_idx = ctx.new_block(&format!("{loop_label}_loop_store.merge")); - let fast_label = ctx.block_label(fast_idx); - let fallback_label = ctx.block_label(fallback_idx); - let merge_label = ctx.block_label(merge_idx); - - { - let blk = ctx.block(); - let guard_i32 = blk.call( - I32, - "js_typed_feedback_numeric_array_index_set_guard", - &[ - (I64, &feedback_site_id), - (DOUBLE, &arr_box), - (I32, idx_i32), - (DOUBLE, &val_double), - (I32, "1"), - ], - ); - let guard_ok = blk.icmp_ne(I32, &guard_i32, "0"); - blk.cond_br(&guard_ok, &fast_label, &fallback_label); - } - - ctx.current_block = fallback_idx; - { - ctx.block().br(side_exit_label); - let fallback = LoweredValue { - semantic: SemanticKind::JsValue, - rep: NativeRep::JsValue, - llvm_ty: DOUBLE, - value: arr_box.clone(), - }; - ctx.record_lowered_value_with_access_mode_and_facts( - array_kind.store_expr_kind(), - Some(arr_id), - array_kind.store_side_exit_consumer(), - &fallback, - Some(BoundsState::Unknown), - None, - Some(BufferAccessMode::DynamicFallback), - Some(MaterializationReason::RuntimeApi), - None, - None, - Vec::new(), - vec![ - array_kind_fact( - Some(arr_id), - "rejected", - array_kind.array_kind_label(), - Some(MaterializationReason::RuntimeApi), - ), - raw_f64_layout_fact( - Some(arr_id), - "rejected", - array_kind.store_guard_detail(), - Some(MaterializationReason::RuntimeApi), - ), - raw_f64_layout_fact( - Some(arr_id), - "invalidated", - "runtime_api", - Some(MaterializationReason::RuntimeApi), - ), - ], - false, - false, - vec![ - "rhs_numeric_guard=side_exit_slow_restart".to_string(), - "store_guard_failure=side_exit_slow_restart".to_string(), - ], - ); - } - - ctx.current_block = fast_idx; - { - let slot_value = { - match array_kind { - PackedNumericLoopKind::F64 => { - let blk = ctx.block(); - canonicalize_raw_f64_numeric_store_value(blk, &val_double) - } - PackedNumericLoopKind::I32 => val_double.clone(), - PackedNumericLoopKind::U32 => val_double.clone(), - } - }; - let fast_arr_box = lower_expr(ctx, &arr_expr)?; - let blk = ctx.block(); - let arr_bits = blk.bitcast_double_to_i64(&fast_arr_box); - let arr_handle = blk.and(I64, &arr_bits, POINTER_MASK_I64); - let idx_i64 = blk.zext(I32, idx_i32, I64); - let byte_offset = blk.shl(I64, &idx_i64, "3"); - let with_header = blk.add(I64, &byte_offset, "8"); - let element_addr = blk.add(I64, &arr_handle, &with_header); - let element_ptr = blk.inttoptr(I64, &element_addr); - // GC_STORE_AUDIT(POINTER_FREE): packed numeric-array element store — - // `slot_value` is a raw numeric f64 (canonicalized via - // `js_array_numeric_value_to_raw_f64` for F64, or `sitofp` of an i32 for - // I32) written into a numeric-layout array element. A number is never a - // GC pointer, so the slot carries no heap edge and needs no barrier. - blk.store(DOUBLE, &slot_value, &element_ptr); - blk.br(&merge_label); - } - let stored = LoweredValue { - semantic: SemanticKind::JsNumber, - rep: match array_kind { - PackedNumericLoopKind::F64 => NativeRep::F64, - PackedNumericLoopKind::I32 => NativeRep::I32, - PackedNumericLoopKind::U32 => NativeRep::U32, - }, - llvm_ty: match array_kind { - PackedNumericLoopKind::F64 => DOUBLE, - PackedNumericLoopKind::I32 => I32, - PackedNumericLoopKind::U32 => I32, - }, - value: native_value, - }; - ctx.record_lowered_value_with_access_mode_and_facts( - array_kind.store_expr_kind(), - Some(arr_id), - array_kind.store_consumer(), - &stored, - Some(BoundsState::Guarded { - guard_id: guard_id.to_string(), - }), - None, - Some(BufferAccessMode::CheckedNative), - None, - None, - None, - vec![ - array_kind_fact( - Some(arr_id), - "consumed", - array_kind.array_kind_label(), - None, - ), - raw_f64_layout_fact(Some(arr_id), "consumed", guard_id, None), - ], - Vec::new(), - false, - false, - { - let mut notes = vec![ - "rhs_numeric_guard=js_typed_feedback_numeric_array_index_set_guard".to_string(), - "array_reloaded_after_rhs=1".to_string(), - "array_reloaded_after_store_guard=1".to_string(), - "store_guard_failure=side_exit_slow_restart".to_string(), - "index_range=nonnegative_i32".to_string(), - "length_range=guarded_i32".to_string(), - format!("storage_layout={}", array_kind.array_kind_label()), - ]; - if matches!(array_kind, PackedNumericLoopKind::F64) { - notes.push("raw_f64_canonicalized=js_array_numeric_value_to_raw_f64".to_string()); - notes.push("array_reloaded_after_canonicalization=1".to_string()); - } - notes.extend(rhs_notes); - notes - }, - ); - ctx.current_block = merge_idx; - Ok(val_double) -} - pub(crate) fn lower( ctx: &mut FnCtx<'_>, expr: &Expr, diff --git a/crates/perry-codegen/src/expr/index_set_packed_loop.rs b/crates/perry-codegen/src/expr/index_set_packed_loop.rs new file mode 100644 index 0000000000..d27e1f7676 --- /dev/null +++ b/crates/perry-codegen/src/expr/index_set_packed_loop.rs @@ -0,0 +1,430 @@ +//! Packed-loop store lowering for indexed assignment: the f64 and numeric +//! range-loop `arr[i] = expr` paths. +//! +//! Split out of `index_set.rs` to keep it under the 2,000-line file gate. + +use anyhow::Result; +use perry_hir::{BinaryOp, Expr}; + +use crate::nanbox::{double_literal, POINTER_MASK_I64}; +use crate::native_value::{ + BoundsState, BufferAccessMode, ExpectedNativeRep, LoweredValue, MaterializationReason, + NativeRep, SemanticKind, +}; +use crate::rooting; +use crate::type_analysis::{is_array_expr, is_numeric_expr, is_string_expr, receiver_class_name}; +use crate::types::{DOUBLE, I32, I64}; + +use super::index_set::{canonicalize_raw_f64_numeric_store_value, packed_f64_loop_fact}; +use super::index_set_typed_array::lower_inline_dyn_typed_array_set; +use super::*; +use super::{ + array_kind_fact, array_store_needs_layout_note, array_store_needs_write_barrier, + attach_buffer_view_pointer_state_for_expr, buffer_access_materialization_reason, + emit_array_numeric_write_note_on_block, emit_jsvalue_slot_store_on_block, + emit_root_nanbox_store_on_block, emit_typed_feedback_register_site, emit_write_barrier, + expr_has_numeric_pointer_free_array_layout, int_range_expr, lower_buffer_store, lower_expr, + lower_expr_as_i32, lower_expr_native, lower_index_set_fast, lower_typed_array_store, + materialize_js_value, nanbox_pointer_inline, raw_f64_layout_fact, unbox_str_handle, + unbox_to_i64, BufferAccessSpec, FnCtx, PackedF64LoopFact, PackedNumericLoopKind, + TypedFeedbackContract, TypedFeedbackKind, +}; + +fn lower_packed_f64_loop_store_value( + ctx: &mut FnCtx<'_>, + arr_id: u32, + value: &Expr, +) -> Result<(String, Vec)> { + if let Expr::MathAbs(operand) = value { + // Only fold to `llvm.fabs.f64` when the inner read is a PROVEN packed-f64 + // load (same array, index is the packed-loop counter). A general + // `arr[key]` can lower through the boxed/runtime fallback to a NaN-boxed + // JS value, and `fabs` (a bare sign-bit clear) would skip `Math.abs`'s + // ToNumber coercion on it. + if let Expr::IndexGet { object, index } = operand.as_ref() { + let proven_packed_load = matches!(object.as_ref(), Expr::LocalGet(id) if *id == arr_id) + && matches!(index.as_ref(), Expr::LocalGet(idx_id) + if packed_f64_loop_fact(ctx, arr_id, *idx_id).is_some()); + if proven_packed_load { + let raw = lower_expr(ctx, operand)?; + let abs = ctx.block().call(DOUBLE, "llvm.fabs.f64", &[(DOUBLE, &raw)]); + return Ok((abs, vec!["rhs_unary_math=llvm.fabs.f64".to_string()])); + } + } + } + Ok((lower_expr(ctx, value)?, Vec::new())) +} + +fn lower_packed_numeric_loop_store_value( + ctx: &mut FnCtx<'_>, + arr_id: u32, + value: &Expr, + array_kind: PackedNumericLoopKind, +) -> Result<(String, String, Vec)> { + match array_kind { + PackedNumericLoopKind::F64 => { + let (value, notes) = lower_packed_f64_loop_store_value(ctx, arr_id, value)?; + Ok((value.clone(), value, notes)) + } + PackedNumericLoopKind::I32 => { + let value_i32 = lower_expr_as_i32(ctx, value)?; + let value_double = ctx.block().sitofp(I32, &value_i32, DOUBLE); + Ok(( + value_double, + value_i32, + vec!["rhs_i32_store=sitofp_i32_to_raw_f64_slot".to_string()], + )) + } + PackedNumericLoopKind::U32 => { + // No packed-U32 store fast path exists yet, and the IndexSet caller + // already routes U32 facts to the generic array-store path (see the + // `!matches!(.., U32)` guard below). This arm is therefore + // unreachable in practice; rather than `bail!` (a whole-compile + // failure) if a future change ever routes a U32 store here, degrade + // to the F64 full-value store. A uint32 is representable exactly in + // f64, so storing the full value is always correct — just not the + // (nonexistent) packed-U32 fast path. See #5464. + let (value, notes) = lower_packed_f64_loop_store_value(ctx, arr_id, value)?; + Ok((value.clone(), value, notes)) + } + } +} + +/// #6011: inline store for the hole-tolerant *range-guarded* packed-f64 loop. +/// +/// The range guard already proved at loop entry that every index this loop +/// can touch is in bounds, that the receiver is a plain, mutable (not +/// frozen/sealed), descriptor-free array, and that its slots are raw-f64 +/// numbers or `TAG_HOLE` — and the matcher proved the body cannot invalidate +/// any of that mid-loop (no calls/closures/awaits, stores only through this +/// path). The only per-iteration check left is on the RHS *value*: a NaN-boxed +/// non-double (string/object/undefined/INT32-boxed int/…) side-exits to the +/// slow loop, which re-executes the current iteration through the generic +/// store (the side exit fires before the store, so nothing double-applies). +/// The store itself is a raw f64 write; overwriting `TAG_HOLE` with a number +/// is exactly JS element definition on an in-bounds index, and a number never +/// carries a heap edge, so no barrier / layout note is needed (the guard +/// (re)asserted the pointer-free GC layout). +pub(super) fn lower_packed_f64_range_loop_index_set( + ctx: &mut FnCtx<'_>, + arr_id: u32, + idx_i32: &str, + value: &Expr, + guard_id: &str, + side_exit_label: &str, +) -> Result { + let (val_double, rhs_notes) = lower_packed_f64_loop_store_value(ctx, arr_id, value)?; + + let fast_idx = ctx.new_block("packed_f64_range_store.fast"); + let exit_idx = ctx.new_block("packed_f64_range_store.side_exit"); + let fast_label = ctx.block_label(fast_idx); + let exit_label = ctx.block_label(exit_idx); + + // Numeric-bits check: (bits >> 48) - 0x7FF9 , + arr_id: u32, + idx_i32: &str, + value: &Expr, + guard_id: &str, + side_exit_label: &str, + array_kind: PackedNumericLoopKind, + allow_holes: bool, +) -> Result { + if allow_holes && matches!(array_kind, PackedNumericLoopKind::F64) { + return lower_packed_f64_range_loop_index_set( + ctx, + arr_id, + idx_i32, + value, + guard_id, + side_exit_label, + ); + } + let (val_double, native_value, rhs_notes) = + lower_packed_numeric_loop_store_value(ctx, arr_id, value, array_kind)?; + let arr_expr = Expr::LocalGet(arr_id); + let arr_box = lower_expr(ctx, &arr_expr)?; + let feedback_site_id = emit_typed_feedback_register_site( + ctx, + TypedFeedbackKind::ArrayElement, + match array_kind { + PackedNumericLoopKind::F64 => "array[packed_f64_loop]=", + PackedNumericLoopKind::I32 => "array[packed_i32_loop]=", + PackedNumericLoopKind::U32 => "array[packed_u32_loop]=", + }, + TypedFeedbackContract::bounded_numeric_array_set_index(), + ); + let loop_label = array_kind.loop_label(); + let fast_idx = ctx.new_block(&format!("{loop_label}_loop_store.fast")); + let fallback_idx = ctx.new_block(&format!("{loop_label}_loop_store.fallback")); + let merge_idx = ctx.new_block(&format!("{loop_label}_loop_store.merge")); + let fast_label = ctx.block_label(fast_idx); + let fallback_label = ctx.block_label(fallback_idx); + let merge_label = ctx.block_label(merge_idx); + + { + let blk = ctx.block(); + let guard_i32 = blk.call( + I32, + "js_typed_feedback_numeric_array_index_set_guard", + &[ + (I64, &feedback_site_id), + (DOUBLE, &arr_box), + (I32, idx_i32), + (DOUBLE, &val_double), + (I32, "1"), + ], + ); + let guard_ok = blk.icmp_ne(I32, &guard_i32, "0"); + blk.cond_br(&guard_ok, &fast_label, &fallback_label); + } + + ctx.current_block = fallback_idx; + { + ctx.block().br(side_exit_label); + let fallback = LoweredValue { + semantic: SemanticKind::JsValue, + rep: NativeRep::JsValue, + llvm_ty: DOUBLE, + value: arr_box.clone(), + }; + ctx.record_lowered_value_with_access_mode_and_facts( + array_kind.store_expr_kind(), + Some(arr_id), + array_kind.store_side_exit_consumer(), + &fallback, + Some(BoundsState::Unknown), + None, + Some(BufferAccessMode::DynamicFallback), + Some(MaterializationReason::RuntimeApi), + None, + None, + Vec::new(), + vec![ + array_kind_fact( + Some(arr_id), + "rejected", + array_kind.array_kind_label(), + Some(MaterializationReason::RuntimeApi), + ), + raw_f64_layout_fact( + Some(arr_id), + "rejected", + array_kind.store_guard_detail(), + Some(MaterializationReason::RuntimeApi), + ), + raw_f64_layout_fact( + Some(arr_id), + "invalidated", + "runtime_api", + Some(MaterializationReason::RuntimeApi), + ), + ], + false, + false, + vec![ + "rhs_numeric_guard=side_exit_slow_restart".to_string(), + "store_guard_failure=side_exit_slow_restart".to_string(), + ], + ); + } + + ctx.current_block = fast_idx; + { + let slot_value = { + match array_kind { + PackedNumericLoopKind::F64 => { + let blk = ctx.block(); + canonicalize_raw_f64_numeric_store_value(blk, &val_double) + } + PackedNumericLoopKind::I32 => val_double.clone(), + PackedNumericLoopKind::U32 => val_double.clone(), + } + }; + let fast_arr_box = lower_expr(ctx, &arr_expr)?; + let blk = ctx.block(); + let arr_bits = blk.bitcast_double_to_i64(&fast_arr_box); + let arr_handle = blk.and(I64, &arr_bits, POINTER_MASK_I64); + let idx_i64 = blk.zext(I32, idx_i32, I64); + let byte_offset = blk.shl(I64, &idx_i64, "3"); + let with_header = blk.add(I64, &byte_offset, "8"); + let element_addr = blk.add(I64, &arr_handle, &with_header); + let element_ptr = blk.inttoptr(I64, &element_addr); + // GC_STORE_AUDIT(POINTER_FREE): packed numeric-array element store — + // `slot_value` is a raw numeric f64 (canonicalized via + // `js_array_numeric_value_to_raw_f64` for F64, or `sitofp` of an i32 for + // I32) written into a numeric-layout array element. A number is never a + // GC pointer, so the slot carries no heap edge and needs no barrier. + blk.store(DOUBLE, &slot_value, &element_ptr); + blk.br(&merge_label); + } + let stored = LoweredValue { + semantic: SemanticKind::JsNumber, + rep: match array_kind { + PackedNumericLoopKind::F64 => NativeRep::F64, + PackedNumericLoopKind::I32 => NativeRep::I32, + PackedNumericLoopKind::U32 => NativeRep::U32, + }, + llvm_ty: match array_kind { + PackedNumericLoopKind::F64 => DOUBLE, + PackedNumericLoopKind::I32 => I32, + PackedNumericLoopKind::U32 => I32, + }, + value: native_value, + }; + ctx.record_lowered_value_with_access_mode_and_facts( + array_kind.store_expr_kind(), + Some(arr_id), + array_kind.store_consumer(), + &stored, + Some(BoundsState::Guarded { + guard_id: guard_id.to_string(), + }), + None, + Some(BufferAccessMode::CheckedNative), + None, + None, + None, + vec![ + array_kind_fact( + Some(arr_id), + "consumed", + array_kind.array_kind_label(), + None, + ), + raw_f64_layout_fact(Some(arr_id), "consumed", guard_id, None), + ], + Vec::new(), + false, + false, + { + let mut notes = vec![ + "rhs_numeric_guard=js_typed_feedback_numeric_array_index_set_guard".to_string(), + "array_reloaded_after_rhs=1".to_string(), + "array_reloaded_after_store_guard=1".to_string(), + "store_guard_failure=side_exit_slow_restart".to_string(), + "index_range=nonnegative_i32".to_string(), + "length_range=guarded_i32".to_string(), + format!("storage_layout={}", array_kind.array_kind_label()), + ]; + if matches!(array_kind, PackedNumericLoopKind::F64) { + notes.push("raw_f64_canonicalized=js_array_numeric_value_to_raw_f64".to_string()); + notes.push("array_reloaded_after_canonicalization=1".to_string()); + } + notes.extend(rhs_notes); + notes + }, + ); + ctx.current_block = merge_idx; + Ok(val_double) +} diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 2a78e17793..2cc7baf037 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -104,8 +104,9 @@ pub(crate) use pod_record::{ try_lower_pod_field_set, }; pub(crate) use proven_view_access::{ - index_is_exact_i32_shape, local_is_proven_int_store_view, + index_is_exact_i32_shape, is_proven_u32_view_read, local_is_proven_int_store_view, try_lower_proven_view_checked_f64_load, try_lower_proven_view_checked_store, + try_lower_proven_view_checked_u32_load, }; pub(crate) use range_facts::{ bounds_for_buffer_access_width, effective_alias_state_for_access, @@ -1643,6 +1644,10 @@ pub(crate) struct VersionedIndexedLoopFact { #[derive(Clone, Debug)] pub(crate) struct StablePackedNumericAccess { + /// One preheader-derived element-zero base for a mode-2 Array-subclass + /// prefix proven wholly inline or wholly spilled. When present, indexed + /// reads need no per-element storage-kind selection. + pub contiguous_base: Option, /// Whether the admitted receiver is a plain Array rather than an /// Array-subclass object. pub is_plain: String, @@ -1656,6 +1661,26 @@ pub(crate) struct StablePackedNumericAccess { pub object_spill_base: String, } +#[derive(Clone, Debug)] +pub(crate) struct StablePackedReadCache { + /// The cache is keyed by the scalar loop counter rather than assumed to + /// expire on the back edge. This remains correct through `continue` edges + /// and lets LLVM promote all three slots without relying on block layout. + pub valid_slot: String, + pub counter_slot: String, + /// A boxed JS value. It is not a GC root: any call that could move a + /// pointer dirties the associated proof before entering the callee, and a + /// dirty cache is never loaded. + pub value_slot: String, + /// Canonical unsigned entity index paired with `value_slot`. Present only + /// when admission proved every element is an exact `u32`; consumers can + /// then reuse the native index without repeating ToUint32 conversion. + pub u32_slot: Option, + /// Compile-time source-order state. The first lowered occurrence only + /// populates the slots; later occurrences emit a runtime hit/miss test. + pub has_producer: bool, +} + #[derive(Clone, Debug)] pub(crate) struct StablePackedLoopFact { pub counter_local_id: u32, @@ -1678,11 +1703,37 @@ pub(crate) struct StablePackedLoopFact { /// use, after those temporaries, so none of their runtime loads can leave a /// stale raw address. pub revalidate_before_indexed_read: bool, + /// Path-sensitive validity bit for a nested-derived raw receiver. Calls + /// set it before entering the callee; a successful exact revalidation + /// clears it. LLVM promotes the compiler-private alloca to SSA, so the + /// clean hot arm is one branch and no runtime call. + pub revalidation_dirty_slot: Option, + /// Non-root cache paired with `revalidation_dirty_slot`. It is read only + /// on the clean arm; a call dirties the proof before a moving collection, + /// and successful revalidation refreshes this word before clearing it. + pub revalidation_live_raw_slot: Option, + /// One exact `array[counter]` result shared by repeated occurrences in the + /// same source iteration. A hit additionally requires a clean revalidation + /// proof, so observable calls force an exact reread at the next occurrence. + pub repeated_read_cache: Option, pub live_receiver_handle: Option, /// Admission scanned the complete indexed range and proved every value is /// an untagged IEEE Number. This is requested only when the indexed value /// appears below a numeric operator in the cloned body. pub numeric_elements: bool, + /// The current guarded typed-array clone uses `array[counter]` as an + /// element key. Its first source occurrence validates and canonicalizes + /// the value to `u32`; repeated occurrences reuse those native bits. + pub u32_index_elements: bool, + /// Minimum immutable length of every pairwise-distinct admitted component + /// column. The entity guard checks its canonical index against this once, + /// allowing every component access in the iteration to be unchecked. + pub u32_component_bound: Option, + /// Equal-length component admission makes an out-of-range entity a + /// no-effect iteration: every typed-array read is `undefined` and every + /// store is ignored. Branch directly to this loop's update rather than + /// restarting the generic clone and replaying earlier effects. + pub u32_out_of_bounds_label: Option, /// Preheader-derived numeric storage bases. Admission proved the complete /// range is raw f64 and the call-free clone keeps these addresses stable. pub numeric_access: Option, @@ -1690,6 +1741,11 @@ pub(crate) struct StablePackedLoopFact { /// read. They may seed a nested candidate only while this fast-loop fact /// is active. pub derived_locals: std::collections::HashSet, + /// Immutable locals initialized from a proven Uint32Array view read in + /// this clone. Their ordinary JS slot still stores the exact Number, while + /// native stores may consume it with ToUint32 semantics without falling + /// back to the dynamic typed-array setter. + pub u32_view_derived_locals: std::collections::HashMap, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -2442,6 +2498,7 @@ pub(crate) use masked_window::masked_window_fact_for_index; mod computed_store_rooting_tests; mod index_set; mod index_set_guarded; +mod index_set_packed_loop; mod index_set_typed_array; mod instance_misc1; mod member_update; @@ -3639,6 +3696,26 @@ pub(crate) fn lower_expr_value(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result + { + let slot = crate::stmt::stable_packed_loop::u32_view_derived_local_slot(ctx, *id) + .expect("guarded derived-u32 slot"); + let lowered = LoweredValue::u32(ctx.block().load(I32, &slot)); + ctx.record_lowered_value( + "LocalGet", + Some(*id), + "stable_packed_u32_view_derived_local", + &lowered, + None, + None, + None, + false, + false, + Vec::new(), + ); + Ok(Some(lowered)) + } Expr::LocalGet(id) if is_plain_f64_local(ctx, *id) => { let slot = ctx .locals diff --git a/crates/perry-codegen/src/expr/proven_view_access.rs b/crates/perry-codegen/src/expr/proven_view_access.rs index 5322fc22f5..e4d88911d8 100644 --- a/crates/perry-codegen/src/expr/proven_view_access.rs +++ b/crates/perry-codegen/src/expr/proven_view_access.rs @@ -168,26 +168,68 @@ fn proven_view_for( if ctx.closure_captures.contains_key(id) { return None; } - if !index_is_exact_i32_shape(ctx, index) { - return None; - } - if !can_lower_expr_as_i32( - index, - &ctx.i32_counter_slots, - ctx.flat_const_arrays, - &ctx.array_row_aliases, - ctx.integer_locals, - &ctx.const_number_locals, - ctx.clamp3_functions, - ctx.clamp_u8_functions, - ctx.integer_returning_functions, - ctx.i32_identity_functions, - ) { - return None; + let stable_u32_index = crate::stmt::stable_packed_loop::has_u32_index_fact(ctx, index); + if !stable_u32_index { + if !index_is_exact_i32_shape(ctx, index) { + return None; + } + if !can_lower_expr_as_i32( + index, + &ctx.i32_counter_slots, + ctx.flat_const_arrays, + &ctx.array_row_aliases, + ctx.integer_locals, + &ctx.const_number_locals, + ctx.clamp3_functions, + ctx.clamp_u8_functions, + ctx.integer_returning_functions, + ctx.i32_identity_functions, + ) { + return None; + } } Some((*id, view)) } +fn lower_checked_index(ctx: &mut FnCtx<'_>, index: &Expr) -> Result { + if let Some(index) = crate::stmt::stable_packed_loop::try_lower_u32_index(ctx, index) { + return Ok(index); + } + lower_expr_as_i32(ctx, index) +} + +pub(crate) fn is_proven_u32_view_read(ctx: &FnCtx<'_>, value: &Expr) -> bool { + let Expr::IndexGet { object, index } = value else { + return false; + }; + let Expr::LocalGet(id) = object.as_ref() else { + return false; + }; + ctx.buffer_view_slots.get(id).is_some_and(|view| { + view.pointer_state.is_stable() + && view.storage_inline_proven + && view.native_owned.is_none() + && view.index_unit == BufferIndexUnit::Element + && view.alias.allows_noalias() + && view.scope_idx.is_some() + && matches!(view.elem, BufferElem::U32) + && crate::stmt::stable_packed_loop::has_u32_index_fact(ctx, index) + }) +} + +fn proven_u32_view_value(ctx: &FnCtx<'_>, value: &Expr) -> bool { + if is_proven_u32_view_read(ctx, value) { + return true; + } + let Expr::LocalGet(id) = value else { + return false; + }; + ctx.stable_packed_loop_facts + .iter() + .rev() + .any(|fact| fact.u32_view_derived_locals.contains_key(id)) +} + /// Data pointer + entry-derived length for the proven view. The length load /// is `invariant` — a non-view typed array's length is immutable. fn load_data_and_len( @@ -234,7 +276,7 @@ pub(crate) fn try_lower_proven_view_checked_f64_load( // no receiver value or backing-store pointer has been materialized yet. // Lower the index expression first, then load `data_slot` below, so even a // collecting proven index leaves no movable or raw address live. - let idx_i32 = lower_expr_as_i32(ctx, index)?; + let idx_i32 = lower_checked_index(ctx, index)?; let (data_ptr, len) = load_data_and_len(ctx, &view); let load_idx = ctx.new_block("pview.get.load"); @@ -303,7 +345,9 @@ pub(crate) fn try_lower_proven_view_checked_f64_load( Some(id), "TypedArrayGet.proven_view_checked", &lowered, - Some(BoundsState::Unknown), + Some(BoundsState::Guarded { + guard_id: "proven_view_checked_bounds".to_string(), + }), Some(view.alias.clone()), Some(BufferAccessMode::CheckedNative), None, @@ -315,6 +359,136 @@ pub(crate) fn try_lower_proven_view_checked_f64_load( Ok(Some(result)) } +/// Inline checked Uint32Array read that preserves the raw lane for a native +/// consumer. The out-of-bounds arm is zero because the public read produces +/// `undefined`, whose ToUint32 value is zero. +pub(crate) fn try_lower_proven_view_checked_u32_load( + ctx: &mut FnCtx<'_>, + object: &Expr, + index: &Expr, +) -> Result> { + let Some((id, view)) = proven_view_for(ctx, object, index) else { + return Ok(None); + }; + if !matches!(view.elem, BufferElem::U32) { + return Ok(None); + } + let common_bound = crate::stmt::stable_packed_loop::has_u32_component_bound(ctx, index); + let idx_i32 = lower_checked_index(ctx, index)?; + let (data_ptr, len) = load_data_and_len(ctx, &view); + if common_bound { + let idx_i64 = ctx.block().zext(I32, &idx_i32, I64); + let byte_off = ctx.block().shl(I64, &idx_i64, "2"); + let elem_ptr = ctx.block().gep(I8, &data_ptr, &[(I64, &byte_off)]); + let lowered = LoweredValue::u32(ctx.block().load(I32, &elem_ptr)); + ctx.record_lowered_value_with_access_mode( + "TypedArrayGet", + Some(id), + "TypedArrayGet.proven_view_common_bound_u32", + &lowered, + Some(BoundsState::Guarded { + guard_id: "stable_packed_u32_component_bound".to_string(), + }), + Some(view.alias.clone()), + Some(BufferAccessMode::UncheckedNative), + None, + false, + false, + vec!["proven_view=unchecked_common_bound_u32".to_string()], + ); + attach_buffer_view_facts(ctx, &view); + return Ok(Some(lowered)); + } + let load_idx = ctx.new_block("pview.get_u32.load"); + let oob_idx = ctx.new_block("pview.get_u32.oob"); + let merge_idx = ctx.new_block("pview.get_u32.merge"); + let load_label = ctx.block_label(load_idx); + let oob_label = ctx.block_label(oob_idx); + let merge_label = ctx.block_label(merge_idx); + let in_bounds = ctx.block().icmp_ult(I32, &idx_i32, &len); + ctx.block().cond_br(&in_bounds, &load_label, &oob_label); + + ctx.current_block = load_idx; + let idx_i64 = ctx.block().zext(I32, &idx_i32, I64); + let byte_off = ctx.block().shl(I64, &idx_i64, "2"); + let elem_ptr = ctx.block().gep(I8, &data_ptr, &[(I64, &byte_off)]); + let raw = ctx.block().load(I32, &elem_ptr); + let load_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = oob_idx; + let oob_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = merge_idx; + let value = ctx.block().phi(I32, &[(&raw, &load_end), ("0", &oob_end)]); + let lowered = LoweredValue::u32(value); + ctx.record_lowered_value_with_access_mode( + "TypedArrayGet", + Some(id), + "TypedArrayGet.proven_view_checked_u32", + &lowered, + Some(BoundsState::Guarded { + guard_id: "proven_view_checked_bounds".to_string(), + }), + Some(view.alias.clone()), + Some(BufferAccessMode::CheckedNative), + None, + false, + false, + vec!["proven_view=checked_inline_u32; guards=none".to_string()], + ); + attach_buffer_view_facts(ctx, &view); + Ok(Some(lowered)) +} + +fn emit_proven_view_store( + ctx: &mut FnCtx<'_>, + view: &crate::native_value::BufferViewSlot, + data_ptr: &str, + idx_i32: &str, + value_native: &LoweredValue, +) { + let blk = ctx.block(); + let idx_i64 = blk.zext(I32, idx_i32, I64); + let byte_off = if view.element_width_bytes > 1 { + blk.shl( + I64, + &idx_i64, + &view.element_width_bytes.trailing_zeros().to_string(), + ) + } else { + idx_i64 + }; + let elem_ptr = blk.gep(I8, data_ptr, &[(I64, &byte_off)]); + // GC_STORE_AUDIT(POINTER_FREE): typed-array backing store. + match view.elem { + BufferElem::I8 | BufferElem::U8 => { + let byte = blk.trunc(I32, &value_native.value, I8); + blk.store(I8, &byte, &elem_ptr); + } + BufferElem::I16 | BufferElem::U16 => { + let half = blk.trunc(I32, &value_native.value, I16); + // GC_STORE_AUDIT(POINTER_FREE): typed-array backing store. + blk.store(I16, &half, &elem_ptr); + } + BufferElem::I32 | BufferElem::U32 => { + // GC_STORE_AUDIT(POINTER_FREE): typed-array backing store. + blk.store(I32, &value_native.value, &elem_ptr); + } + BufferElem::F32 => { + let narrow = blk.fptrunc(DOUBLE, &value_native.value, F32); + // GC_STORE_AUDIT(POINTER_FREE): typed-array backing store. + blk.store(F32, &narrow, &elem_ptr); + } + BufferElem::F64 => { + // GC_STORE_AUDIT(POINTER_FREE): typed-array backing store. + blk.store(DOUBLE, &value_native.value, &elem_ptr); + } + BufferElem::U8Clamped => unreachable!("gated before store emission"), + } +} + /// Inline checked element write (`object[index] = value`). The value is /// evaluated and coerced BEFORE the bounds check (spec order); an OOB write is /// a silent no-op. Returns the lowered (coerced-native) RHS so the assignment @@ -351,6 +525,7 @@ pub(crate) fn try_lower_proven_view_checked_store( if int_kind && !super::can_lower_integer_typed_array_store_value(ctx, value) && !super::can_lower_expr_as_i32_in_current_region(ctx, value) + && !proven_u32_view_value(ctx, value) { return Ok(None); } @@ -365,7 +540,7 @@ pub(crate) fn try_lower_proven_view_checked_store( } } - let idx_i32 = lower_expr_as_i32(ctx, index)?; + let idx_i32 = lower_checked_index(ctx, index)?; let value_native = if int_kind { let expected = if matches!(view.elem, BufferElem::U32) { ExpectedNativeRep::U32 @@ -386,6 +561,26 @@ pub(crate) fn try_lower_proven_view_checked_store( lower_expr_native(ctx, value, ExpectedNativeRep::F64)? }; let (data_ptr, len) = load_data_and_len(ctx, &view); + if crate::stmt::stable_packed_loop::has_u32_component_bound(ctx, index) { + emit_proven_view_store(ctx, &view, &data_ptr, &idx_i32, &value_native); + ctx.record_lowered_value_with_access_mode( + "TypedArraySet", + Some(id), + "TypedArraySet.proven_view_common_bound", + &value_native, + Some(BoundsState::Guarded { + guard_id: "stable_packed_u32_component_bound".to_string(), + }), + Some(view.alias.clone()), + Some(BufferAccessMode::UncheckedNative), + None, + false, + false, + vec!["proven_view=unchecked_common_bound".to_string()], + ); + attach_buffer_view_facts(ctx, &view); + return Ok(Some(value_native)); + } let store_idx = ctx.new_block("pview.set.store"); let done_idx = ctx.new_block("pview.set.done"); @@ -400,52 +595,8 @@ pub(crate) fn try_lower_proven_view_checked_store( ctx.current_block = store_idx; { - let blk = ctx.block(); - let idx_i64 = blk.zext(I32, &idx_i32, I64); - let byte_off = if view.element_width_bytes > 1 { - blk.shl( - I64, - &idx_i64, - &view.element_width_bytes.trailing_zeros().to_string(), - ) - } else { - idx_i64 - }; - let elem_ptr = blk.gep(I8, &data_ptr, &[(I64, &byte_off)]); - // Every arm below stores into `elem_ptr`, which addresses the view's - // BACKING STORE (`view.data_slot`). Typed-array elements are raw - // numeric bytes and can never hold a JSValue, so none of these stores - // creates a heap edge and none needs a write barrier. This is the - // codegen-side counterpart of the runtime carve-out for the - // `typedarray` / `typedarray_view` / `buffer` modules - // (`is_pointer_free_module` in scripts/gc_store_site_inventory.py). - match view.elem { - BufferElem::I8 | BufferElem::U8 => { - let byte = blk.trunc(I32, &value_native.value, I8); - // GC_STORE_AUDIT(POINTER_FREE): typed-array backing store. - blk.store(I8, &byte, &elem_ptr); - } - BufferElem::I16 | BufferElem::U16 => { - let half = blk.trunc(I32, &value_native.value, I16); - // GC_STORE_AUDIT(POINTER_FREE): typed-array backing store. - blk.store(I16, &half, &elem_ptr); - } - BufferElem::I32 | BufferElem::U32 => { - // GC_STORE_AUDIT(POINTER_FREE): typed-array backing store. - blk.store(I32, &value_native.value, &elem_ptr); - } - BufferElem::F32 => { - let narrow = blk.fptrunc(DOUBLE, &value_native.value, F32); - // GC_STORE_AUDIT(POINTER_FREE): typed-array backing store. - blk.store(F32, &narrow, &elem_ptr); - } - BufferElem::F64 => { - // GC_STORE_AUDIT(POINTER_FREE): typed-array backing store. - blk.store(DOUBLE, &value_native.value, &elem_ptr); - } - BufferElem::U8Clamped => unreachable!("gated above"), - } - blk.br(&done_label); + emit_proven_view_store(ctx, &view, &data_ptr, &idx_i32, &value_native); + ctx.block().br(&done_label); } ctx.current_block = done_idx; @@ -454,7 +605,9 @@ pub(crate) fn try_lower_proven_view_checked_store( Some(id), "TypedArraySet.proven_view_checked", &value_native, - Some(BoundsState::Unknown), + Some(BoundsState::Guarded { + guard_id: "proven_view_checked_bounds".to_string(), + }), Some(view.alias.clone()), Some(BufferAccessMode::CheckedNative), None, diff --git a/crates/perry-codegen/src/expr/shadow_slot.rs b/crates/perry-codegen/src/expr/shadow_slot.rs index 6b89e15e78..15c7887786 100644 --- a/crates/perry-codegen/src/expr/shadow_slot.rs +++ b/crates/perry-codegen/src/expr/shadow_slot.rs @@ -89,11 +89,13 @@ pub(crate) fn expr_is_known_non_pointer_shadow_value(ctx: &FnCtx<'_>, expr: &Exp // #6750 follow-up: a masked-index read covered by an ACTIVE // masked-window fact is a guard-proven numeric element load — never // a pointer — even when the receiver's static type is erased. - Expr::IndexGet { object, index } => matches!( - object.as_ref(), - Expr::LocalGet(arr_id) - if super::masked_window_fact_for_index(ctx, *arr_id, index).is_some() - ), + Expr::IndexGet { object, index } => { + matches!( + object.as_ref(), + Expr::LocalGet(arr_id) + if super::masked_window_fact_for_index(ctx, *arr_id, index).is_some() + ) || super::is_proven_u32_view_read(ctx, expr) + } // #6996: a typed-array / Buffer element read is a number (or // `undefined` out of range) BY CONSTRUCTION -- `lower_buffer_load`'s // inline byte load, `js_uint8array_index_get_value` and diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index 221cf470f0..2bd7a89eed 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -407,6 +407,11 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { &[I64, DOUBLE, I32, I32], ); module.declare_function("js_typed_array_masked_window_data_ptr", I64, &[DOUBLE]); + module.declare_function( + "js_packed_ecs_u32_loop_guard", + I64, + &[DOUBLE, DOUBLE, DOUBLE, DOUBLE, DOUBLE, DOUBLE, I32, PTR], + ); module.declare_function( "js_typed_feedback_packed_u32_array_loop_guard", I32, diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 319f76c777..3a2d17ff92 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -13,7 +13,8 @@ use crate::expr::{ lower_expr_with_expected_type, unbox_str_handle, }; use crate::native_value::{ - LoweredValue, MaterializationReason, NativeRep, PodLayoutDecision, PodLocal, SemanticKind, + ExpectedNativeRep, LoweredValue, MaterializationReason, NativeRep, PodLayoutDecision, PodLocal, + SemanticKind, }; use crate::type_analysis::is_string_expr; use crate::types::{DOUBLE, I1, I32, I64, I8, PTR}; @@ -1696,7 +1697,15 @@ pub(crate) fn lower_let( false }; let v = if !used_i32_init { - let native_init = if matches!( + let derived_u32 = + crate::stmt::stable_packed_loop::u32_view_derived_local_slot(ctx, id).is_some(); + let native_init = if derived_u32 { + Some(crate::expr::lower_expr_native( + ctx, + init_expr, + ExpectedNativeRep::U32, + )?) + } else if matches!( refined_ty, perry_hir::types::Type::Number | perry_hir::types::Type::Int32 ) || (matches!(refined_ty, perry_hir::types::Type::Boolean) @@ -1739,6 +1748,11 @@ pub(crate) fn lower_let( ); v } else if matches!(lowered.rep, NativeRep::U32 | NativeRep::BufferLen) { + if let Some(native_slot) = + crate::stmt::stable_packed_loop::u32_view_derived_local_slot(ctx, id) + { + ctx.block().store(I32, &lowered.value, &native_slot); + } let v = ctx.block().uitofp(I32, &lowered.value, DOUBLE); ctx.block().store(DOUBLE, &v, &slot); ctx.record_lowered_value( diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index aeb0e4eda0..949a86d579 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -5270,6 +5270,13 @@ pub(super) fn lower_for_after_init_with_i32_bound( let body_label = ctx.block_label(body_idx); let update_label = ctx.block_label(update_idx); let exit_label = ctx.block_label(exit_idx); + if let Some(fact) = ctx + .stable_packed_loop_facts + .last_mut() + .filter(|fact| fact.u32_component_bound.is_some()) + { + fact.u32_out_of_bounds_label = Some(update_label.clone()); + } // Branch from the block holding the init into the cond block. ctx.block().br(&cond_label); diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index 98a682b315..7b52c4a0d8 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -29,6 +29,7 @@ mod masked_window_region; #[cfg(test)] mod prealloc_module_global_tests; pub(crate) mod stable_packed_loop; +mod stable_packed_typed_array; mod switch_stmt; mod try_stmt; mod unused_expr; diff --git a/crates/perry-codegen/src/stmt/stable_packed_loop.rs b/crates/perry-codegen/src/stmt/stable_packed_loop.rs index a41e5f46d2..a65929016e 100644 --- a/crates/perry-codegen/src/stmt/stable_packed_loop.rs +++ b/crates/perry-codegen/src/stmt/stable_packed_loop.rs @@ -8,7 +8,7 @@ use anyhow::Result; use perry_hir::{CompareOp, Expr, Stmt, UpdateOp}; -use crate::expr::{FnCtx, StablePackedLoopFact, StablePackedNumericAccess}; +use crate::expr::{FnCtx, StablePackedLoopFact, StablePackedNumericAccess, StablePackedReadCache}; use crate::native_value::{BoundsState, BufferAccessMode, LoweredValue, MaterializationReason}; use crate::types::{DOUBLE, I1, I32, I64, PTR}; @@ -23,10 +23,31 @@ struct Candidate { array_id: u32, bound: LoopBound, numeric_elements: bool, + u32_index_elements: bool, capture_index: Option, capture_uses_box: bool, nested_derived: bool, nested_requires_access_revalidation: bool, + cache_repeated_index_reads: bool, +} + +fn required_numeric_mode(numeric_elements: bool, u32_index_elements: bool) -> &'static str { + if u32_index_elements { + "2" + } else if numeric_elements { + "1" + } else { + "0" + } +} + +fn exact_target_read(expr: &Expr, array_id: u32, counter_id: u32) -> bool { + matches!( + expr, + Expr::IndexGet { object, index } + if matches!(object.as_ref(), Expr::LocalGet(id) if *id == array_id) + && matches!(index.as_ref(), Expr::LocalGet(id) if *id == counter_id) + ) } fn target_below_numeric_operator( @@ -35,12 +56,7 @@ fn target_below_numeric_operator( counter_id: u32, numeric_context: bool, ) -> bool { - if matches!( - expr, - Expr::IndexGet { object, index } - if matches!(object.as_ref(), Expr::LocalGet(id) if *id == array_id) - && matches!(index.as_ref(), Expr::LocalGet(id) if *id == counter_id) - ) { + if exact_target_read(expr, array_id, counter_id) { return numeric_context; } if matches!(expr, Expr::Closure { .. }) { @@ -59,6 +75,56 @@ fn target_below_numeric_operator( found } +/// Whether the admitted read is used as the complete key of another indexed +/// access. A guarded typed-array loop validates and canonicalizes this value +/// once per source iteration, then reuses the native `u32` at every component +/// access. +fn target_is_index_key(expr: &Expr, array_id: u32, counter_id: u32) -> bool { + if matches!(expr, Expr::Closure { .. }) { + return false; + } + let is_target = |candidate: &Expr| exact_target_read(candidate, array_id, counter_id); + match expr { + Expr::IndexGet { object, index } + | Expr::IndexSet { object, index, .. } + | Expr::IndexUpdate { object, index, .. } => { + if is_target(index) { + return true; + } + target_is_index_key(object, array_id, counter_id) + || target_is_index_key(index, array_id, counter_id) + } + Expr::PutValueSet { + target, + key, + value, + receiver, + .. + } => { + if is_target(key) { + return true; + } + [ + target.as_ref(), + key.as_ref(), + value.as_ref(), + receiver.as_ref(), + ] + .into_iter() + .any(|child| target_is_index_key(child, array_id, counter_id)) + } + _ => { + let mut found = false; + perry_hir::walker::walk_expr_children(expr, &mut |child| { + if !found && target_is_index_key(child, array_id, counter_id) { + found = true; + } + }); + found + } + } +} + fn leading_read_requires_numeric(body: &[Stmt], array_id: u32, counter_id: u32) -> bool { let Some(first) = body.first() else { return false; @@ -75,6 +141,22 @@ fn leading_read_requires_numeric(body: &[Stmt], array_id: u32, counter_id: u32) target_below_numeric_operator(expr, array_id, counter_id, false) } +fn leading_read_requires_u32_index(body: &[Stmt], array_id: u32, counter_id: u32) -> bool { + let Some(first) = body.first() else { + return false; + }; + let expr = match first { + Stmt::Let { + init: Some(expr), .. + } + | Stmt::Expr(expr) + | Stmt::Throw(expr) + | Stmt::Return(Some(expr)) => expr, + _ => return false, + }; + target_is_index_key(expr, array_id, counter_id) +} + fn expr_flags(expr: &Expr, array_id: u32, counter_id: u32, target: &mut bool, call: &mut bool) { if matches!( expr, @@ -111,6 +193,150 @@ fn stmt_flags(stmt: &Stmt, array_id: u32, counter_id: u32) -> (bool, bool) { (target, call) } +/// Count exact `receiver[counter]` reads without descending into closures. +fn target_read_count(expr: &Expr, array_id: u32, counter_id: u32) -> usize { + if matches!( + expr, + Expr::IndexGet { object, index } + if matches!(object.as_ref(), Expr::LocalGet(id) if *id == array_id) + && matches!(index.as_ref(), Expr::LocalGet(id) if *id == counter_id) + ) { + return 1; + } + if matches!(expr, Expr::Closure { .. }) { + return 0; + } + let mut count = 0; + perry_hir::walker::walk_expr_children(expr, &mut |child| { + count += target_read_count(child, array_id, counter_id); + }); + count +} + +/// Count exact target reads in the straight-line statements admitted here. +fn body_target_read_count(body: &[Stmt], array_id: u32, counter_id: u32) -> usize { + body.iter() + .map(|stmt| match stmt { + Stmt::Let { + init: Some(expr), .. + } + | Stmt::Expr(expr) + | Stmt::Throw(expr) + | Stmt::Return(Some(expr)) => target_read_count(expr, array_id, counter_id), + _ => 0, + }) + .sum() +} + +/// A repeated element value can survive calls only through the dirty-bit +/// protocol below. Direct writes need a separate alias argument. A statically +/// proven TypedArray store is brand-disjoint from the admitted +/// Array/Array-subclass receiver. An erased IndexSet has the same property on +/// its sole no-call arm; every other brand crosses a dirtying runtime call. +/// Property writes, statically Array stores, and in-place Array operations +/// disable value caching. +fn indexed_store_direct_arm_is_brand_disjoint(ctx: &FnCtx<'_>, object: &Expr) -> bool { + matches!( + crate::type_analysis::static_type_of(ctx, object), + None | Some(perry_hir::types::Type::Any) | Some(perry_hir::types::Type::Unknown) + ) || crate::type_analysis::is_typed_array_expr(ctx, object) +} + +/// Whether `expr` has a direct mutation arm that can alias the cached Array. +fn expr_blocks_repeated_read_cache(ctx: &FnCtx<'_>, expr: &Expr) -> bool { + if matches!( + expr, + Expr::PropertySet { .. } + | Expr::PropertyUpdate { .. } + | Expr::SuperPropertySet { .. } + | Expr::ObjectSuperPropertySet { .. } + | Expr::ObjectAssign { .. } + | Expr::ObjectDefineProperty(..) + | Expr::ObjectDefineProperties(..) + | Expr::ObjectSetPrototypeOf(..) + | Expr::ArrayPush { .. } + | Expr::ArrayPushSpread { .. } + | Expr::ArrayPop(..) + | Expr::ArrayShift(..) + | Expr::ArrayUnshift { .. } + | Expr::ArraySplice { .. } + | Expr::ArraySort { .. } + | Expr::ArrayReverseValue { .. } + | Expr::ArrayCopyWithin { .. } + | Expr::ArrayCopyWithinValue { .. } + ) { + return true; + } + if let Expr::IndexSet { object, .. } = expr { + // An erased IndexSet's only no-call direct arm is the guarded + // TypedArray store; every other brand reaches `js_dyn_index_set`, + // which dirties the proof before mutating. This is exactly Wolf's + // unannotated component-column shape. A statically Array-typed store, + // on the other hand, can directly mutate an alias of the source. + if !indexed_store_direct_arm_is_brand_disjoint(ctx, object) { + return true; + } + } + if let Expr::PutValueSet { + target, + key, + receiver, + .. + } = expr + { + // Source assignments reach HIR as PutValueSet. The codegen's narrow + // same-receiver, non-string-key route immediately delegates to the + // IndexSet arm described above. Match only the side-effect-free local + // identity form here; every explicit-receiver or computed-base form + // remains conservatively blocked. + let same_local = matches!( + (target.as_ref(), receiver.as_ref()), + (Expr::LocalGet(target_id), Expr::LocalGet(receiver_id)) + if target_id == receiver_id + ); + let static_string_or_symbol = matches!( + key.as_ref(), + Expr::String(_) | Expr::WtfString(_) | Expr::SymbolFor(_) + ) || crate::type_analysis::is_string_expr(ctx, key); + if !same_local + || static_string_or_symbol + || !indexed_store_direct_arm_is_brand_disjoint(ctx, target) + { + return true; + } + } + if let Expr::IndexUpdate { object, .. } = expr { + // The update lowering has more direct receiver arms than IndexSet, so + // require a static TypedArray brand rather than admitting `any`. + if !crate::type_analysis::is_typed_array_expr(ctx, object) { + return true; + } + } + if matches!(expr, Expr::Closure { .. }) { + return false; + } + let mut blocked = false; + perry_hir::walker::walk_expr_children(expr, &mut |child| { + if !blocked && expr_blocks_repeated_read_cache(ctx, child) { + blocked = true; + } + }); + blocked +} + +/// Whether any admitted body statement can directly invalidate the cache. +fn body_blocks_repeated_read_cache(ctx: &FnCtx<'_>, body: &[Stmt]) -> bool { + body.iter().any(|stmt| match stmt { + Stmt::Let { + init: Some(expr), .. + } + | Stmt::Expr(expr) + | Stmt::Throw(expr) + | Stmt::Return(Some(expr)) => expr_blocks_repeated_read_cache(ctx, expr), + _ => false, + }) +} + /// The direct read must be in the first straight-line statement and before any /// explicit user call. Later statements may allocate or invoke callbacks: the /// next iteration reloads the root and validates before using it again. @@ -274,6 +500,9 @@ fn match_candidate( let nested_derived = derived_parent.is_some(); let nested_requires_access_revalidation = derived_parent .is_some_and(|fact| fact.revalidate_each_iteration || fact.revalidate_before_indexed_read); + let cache_repeated_index_reads = nested_requires_access_revalidation + && body_target_read_count(body, array_id, counter_id) > 1 + && !body_blocks_repeated_read_cache(ctx, body); let storage_is_available = capture_index.is_some() || (ctx.locals.contains_key(&array_id) && !ctx.boxed_vars.contains(&array_id)) || (!ctx.locals.contains_key(&array_id) && ctx.module_globals.contains_key(&array_id)); @@ -337,15 +566,19 @@ fn match_candidate( } return None; } + let u32_index_elements = leading_read_requires_u32_index(body, array_id, counter_id); Some(Candidate { counter_id, array_id, bound, - numeric_elements: leading_read_requires_numeric(body, array_id, counter_id), + numeric_elements: u32_index_elements + || leading_read_requires_numeric(body, array_id, counter_id), + u32_index_elements, capture_index, capture_uses_box: capture_index.is_some() && ctx.boxed_vars.contains(&array_id), nested_derived, nested_requires_access_revalidation, + cache_repeated_index_reads, }) } @@ -356,6 +589,12 @@ pub(super) fn record_derived_local(ctx: &mut FnCtx<'_>, id: u32, init: &Expr, mu if mutable || ctx.reassigned_locals.contains(&id) { return; } + if crate::expr::is_proven_u32_view_read(ctx, init) { + let native_slot = ctx.func.alloca_entry(I32); + if let Some(fact) = ctx.stable_packed_loop_facts.last_mut() { + fact.u32_view_derived_locals.insert(id, native_slot); + } + } let Expr::IndexGet { object, index } = init else { return; }; @@ -374,6 +613,13 @@ pub(super) fn record_derived_local(ctx: &mut FnCtx<'_>, id: u32, init: &Expr, mu fact.derived_locals.insert(id); } +pub(crate) fn u32_view_derived_local_slot(ctx: &FnCtx<'_>, id: u32) -> Option { + ctx.stable_packed_loop_facts + .iter() + .rev() + .find_map(|fact| fact.u32_view_derived_locals.get(&id).cloned()) +} + fn descriptor_word(ctx: &mut FnCtx<'_>, descriptor: &str, index: u64) -> String { let ptr = ctx .block() @@ -389,6 +635,7 @@ fn build_numeric_access( ctx: &mut FnCtx<'_>, descriptor: &str, live_raw: &str, + contiguous_u32_prefix: bool, ) -> StablePackedNumericAccess { let kind = descriptor_word(ctx, descriptor, 0); let is_plain = ctx.block().icmp_eq(I64, &kind, "1"); @@ -465,7 +712,20 @@ fn build_numeric_access( let safe_spill = ctx.block().select(I1, &has_spill, I64, &spill, live_raw); let spill_offset = ctx.block().add(I64, &element_bytes, "8"); let object_spill_base = ctx.block().add(I64, &safe_spill, &spill_offset); + let contiguous_base = contiguous_u32_prefix.then(|| { + // Mode-2 admission rejects prefixes that cross the inline/spill + // boundary (and rejects plain Arrays), so storage selection belongs + // in this preheader rather than in every entity iteration. + ctx.block().select( + I1, + &has_inline, + I64, + &object_inline_base, + &object_spill_base, + ) + }); StablePackedNumericAccess { + contiguous_base, is_plain, plain_base, object_inline_count, @@ -516,6 +776,9 @@ fn record_artifacts(ctx: &mut FnCtx<'_>, candidate: &Candidate, receiver: &str) .push("nested_read_miss=generic_read_without_iteration_replay".to_string()); } } + if candidate.cache_repeated_index_reads { + selected_facts.push("same_counter_read_cache=call_invalidated".to_string()); + } ctx.record_lowered_value_with_access_mode_and_facts( "StablePackedArraylikeLoop", Some(array_id), @@ -580,17 +843,40 @@ pub(crate) fn try_lower_index_get( // so it must dominate both successors. let counter_slot = ctx.i32_counter_slots.get(counter_id)?.clone(); let idx_i32 = ctx.block().load(I32, &counter_slot); + let repeated_read_cache = begin_repeated_read_cache(ctx, &fact, &idx_i32); let mut per_read_fallback = None; + let mut per_read_live_raw = None; + let mut per_read_numeric_access = None; if fact.revalidate_before_indexed_read { + let dirty_slot = fact.revalidation_dirty_slot.as_ref()?; + let live_raw_slot = fact.revalidation_live_raw_slot.as_ref()?; let receiver_slot = ctx.locals.get(array_id)?.clone(); let receiver = ctx.block().load(DOUBLE, &receiver_slot); + let dirty = ctx.block().load(I1, dirty_slot); + let validate_idx = ctx.new_block("stable_packed.indexed_read.proof_dirty"); + let clean_idx = ctx.new_block("stable_packed.indexed_read.proof_clean"); + let live_merge_idx = ctx.new_block("stable_packed.indexed_read.live_merge"); + let validate_label = ctx.block_label(validate_idx); + let clean_label = ctx.block_label(clean_idx); + let live_merge_label = ctx.block_label(live_merge_idx); + ctx.block().cond_br(&dirty, &validate_label, &clean_label); + + ctx.current_block = clean_idx; + let clean_raw = ctx.block().load(I64, live_raw_slot); + let clean_end = ctx.block().label.clone(); + ctx.block().br(&live_merge_label); + + ctx.current_block = validate_idx; let live_raw = ctx.block().call( I64, "js_packed_arraylike_loop_revalidate_live", &[ (DOUBLE, &receiver), (DOUBLE, &fact.bound), - (I32, if fact.numeric_elements { "1" } else { "0" }), + ( + I32, + required_numeric_mode(fact.numeric_elements, fact.u32_index_elements), + ), (PTR, &fact.descriptor), ], ); @@ -608,25 +894,41 @@ pub(crate) fn try_lower_index_get( // revalidation therefore cannot side-exit to the generic loop at the // current counter: that would replay the earlier effects. Fall back // for this one indexed read and merge back at the exact source point. - let fallback_idx = ctx.new_block("packed_index.generic_fallback"); - let read_merge_idx = ctx.new_block("packed_index.revalidated_merge"); let continue_label = ctx.block_label(continue_idx); - let fallback_label = ctx.block_label(fallback_idx); - ctx.block().cond_br(&pass, &continue_label, &fallback_label); + let fallback = (!fact.u32_index_elements).then(|| { + let fallback_idx = ctx.new_block("packed_index.generic_fallback"); + let read_merge_idx = ctx.new_block("packed_index.revalidated_merge"); + let fallback_label = ctx.block_label(fallback_idx); + (fallback_idx, read_merge_idx, fallback_label) + }); + let miss_label = fallback + .as_ref() + .map(|(_, _, label)| label.as_str()) + .unwrap_or(fact.side_exit_label.as_str()); + ctx.block().cond_br(&pass, &continue_label, miss_label); ctx.current_block = continue_idx; - let numeric_access = fact - .numeric_elements - .then(|| build_numeric_access(ctx, &fact.descriptor, &live_raw)); - let active = ctx - .stable_packed_loop_facts - .iter_mut() - .rev() - .find(|active| { - active.array_local_id == *array_id && active.counter_local_id == *counter_id - })?; - active.live_receiver_handle = Some(live_raw); - active.numeric_access = numeric_access; - per_read_fallback = Some((fallback_idx, read_merge_idx, fallback_label, receiver)); + ctx.block().store(I64, &live_raw, live_raw_slot); + ctx.block().store(I1, "0", dirty_slot); + let validated_end = ctx.block().label.clone(); + ctx.block().br(&live_merge_label); + + ctx.current_block = live_merge_idx; + let merged_live_raw = ctx.block().phi( + I64, + &[(&clean_raw, &clean_end), (&live_raw, &validated_end)], + ); + per_read_numeric_access = fact.numeric_elements.then(|| { + build_numeric_access( + ctx, + &fact.descriptor, + &merged_live_raw, + fact.u32_index_elements, + ) + }); + per_read_live_raw = Some(merged_live_raw); + if let Some((fallback_idx, read_merge_idx, fallback_label)) = fallback { + per_read_fallback = Some((fallback_idx, read_merge_idx, fallback_label, receiver)); + } } let fact = ctx .stable_packed_loop_facts @@ -634,10 +936,31 @@ pub(crate) fn try_lower_index_get( .rev() .find(|fact| fact.array_local_id == *array_id && fact.counter_local_id == *counter_id)? .clone(); - let raw = fact.live_receiver_handle?; + let u32_oob_label = u32_out_of_bounds_label(&fact).to_string(); + let raw = per_read_live_raw.or(fact.live_receiver_handle)?; let idx_i64 = ctx.block().zext(I32, &idx_i32, I64); - if let Some(access) = fact.numeric_access { + if let Some(access) = per_read_numeric_access.or(fact.numeric_access) { let byte_offset = ctx.block().shl(I64, &idx_i64, "3"); + if let Some(base) = access.contiguous_base.as_ref() { + let element_addr = ctx.block().add(I64, base, &byte_offset); + let element_ptr = ctx.block().inttoptr(I64, &element_addr); + let (direct, native_u32) = if fact.u32_index_elements { + let native = ctx.block().load(I32, &element_ptr); + (ctx.block().uitofp(I32, &native, DOUBLE), Some(native)) + } else { + (ctx.block().load(DOUBLE, &element_ptr), None) + }; + let resolved = finish_revalidated_read(ctx, direct, idx_i32.clone(), per_read_fallback); + return Some(finish_repeated_read_cache( + ctx, + resolved, + idx_i32, + repeated_read_cache, + native_u32.as_deref(), + &u32_oob_label, + fact.u32_component_bound.as_deref(), + )); + } let plain_addr = ctx.block().add(I64, &access.plain_base, &byte_offset); let inline_addr = ctx .block() @@ -656,11 +979,15 @@ pub(crate) fn try_lower_index_get( .select(I1, &access.is_plain, I64, &plain_addr, &object_addr); let element_ptr = ctx.block().inttoptr(I64, &element_addr); let direct = ctx.block().load(DOUBLE, &element_ptr); - return Some(finish_revalidated_read( + let resolved = finish_revalidated_read(ctx, direct, idx_i32.clone(), per_read_fallback); + return Some(finish_repeated_read_cache( ctx, - direct, + resolved, idx_i32, - per_read_fallback, + repeated_read_cache, + None, + &u32_oob_label, + fact.u32_component_bound.as_deref(), )); } let kind = descriptor_word(ctx, &fact.descriptor, 0); @@ -784,15 +1111,166 @@ pub(crate) fn try_lower_index_get( (&spill_value, &spill_end), ], ); - Some(finish_revalidated_read( + let resolved = finish_revalidated_read(ctx, direct, idx_i32.clone(), per_read_fallback); + Some(finish_repeated_read_cache( ctx, - direct, + resolved, idx_i32, - per_read_fallback, + repeated_read_cache, + None, + &u32_oob_label, + fact.u32_component_bound.as_deref(), )) } type PerReadFallback = (usize, usize, String, String); +enum RepeatedReadCacheMiss { + Populate(StablePackedReadCache), + Lookup { + cache: StablePackedReadCache, + cached: String, + hit_end: String, + merge_idx: usize, + }, +} + +/// Enter the miss arm of a same-counter value cache. A cached value is read +/// only when no semantic call has executed since it was produced; this is what +/// makes an unrooted boxed pointer safe under the moving collector as well as +/// preserving getters, proxies, and mutation between source occurrences. +fn begin_repeated_read_cache( + ctx: &mut FnCtx<'_>, + fact: &StablePackedLoopFact, + idx_i32: &str, +) -> Option { + let mut cache = fact.repeated_read_cache.clone()?; + let active = ctx + .stable_packed_loop_facts + .iter_mut() + .rev() + .find(|active| { + active.array_local_id == fact.array_local_id + && active.counter_local_id == fact.counter_local_id + })?; + if !active + .repeated_read_cache + .as_ref() + .is_some_and(|active_cache| active_cache.has_producer) + { + active + .repeated_read_cache + .as_mut() + .expect("active repeated-read cache") + .has_producer = true; + cache.has_producer = true; + return Some(RepeatedReadCacheMiss::Populate(cache)); + } + let dirty_slot = fact.revalidation_dirty_slot.as_ref()?; + let valid = ctx.block().load(I1, &cache.valid_slot); + let cached_counter = ctx.block().load(I32, &cache.counter_slot); + let same_counter = ctx.block().icmp_eq(I32, &cached_counter, idx_i32); + let dirty = ctx.block().load(I1, dirty_slot); + let clean = ctx.block().icmp_eq(I1, &dirty, "0"); + let valid_and_same = ctx.block().and(I1, &valid, &same_counter); + let hit = ctx.block().and(I1, &valid_and_same, &clean); + let hit_idx = ctx.new_block("stable_packed.indexed_read.cache_hit"); + let miss_idx = ctx.new_block("stable_packed.indexed_read.cache_miss"); + let merge_idx = ctx.new_block("stable_packed.indexed_read.cache_merge"); + let hit_label = ctx.block_label(hit_idx); + let miss_label = ctx.block_label(miss_idx); + let merge_label = ctx.block_label(merge_idx); + ctx.block().cond_br(&hit, &hit_label, &miss_label); + + ctx.current_block = hit_idx; + let cached = ctx.block().load(DOUBLE, &cache.value_slot); + let hit_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = miss_idx; + Some(RepeatedReadCacheMiss::Lookup { + cache, + cached, + hit_end, + merge_idx, + }) +} + +/// Publish a miss value, then merge it with any previously emitted hit arm. +fn finish_repeated_read_cache( + ctx: &mut FnCtx<'_>, + resolved: String, + idx_i32: String, + cache_miss: Option, + native_u32: Option<&str>, + side_exit_label: &str, + component_bound: Option<&str>, +) -> String { + let Some(cache_miss) = cache_miss else { + return resolved; + }; + let (cache, hit) = match cache_miss { + RepeatedReadCacheMiss::Populate(cache) => (cache, None), + RepeatedReadCacheMiss::Lookup { + cache, + cached, + hit_end, + merge_idx, + } => (cache, Some((cached, hit_end, merge_idx))), + }; + ctx.block().store(I32, &idx_i32, &cache.counter_slot); + ctx.block().store(DOUBLE, &resolved, &cache.value_slot); + if let Some(u32_slot) = cache.u32_slot.as_ref() { + // Validate the entity id at its first source occurrence. A miss exits + // before the conservative typed-array clone can perform an observable + // effect, while hits reuse these exact native bits for every later + // component access in the same source iteration. + let canonical = + emit_canonical_u32_guard(ctx, &resolved, native_u32, side_exit_label, component_bound); + ctx.block().store(I32, &canonical, u32_slot); + } + ctx.block().store(I1, "1", &cache.valid_slot); + let Some((cached, hit_end, merge_idx)) = hit else { + return resolved; + }; + let miss_end = ctx.block().label.clone(); + let merge_label = ctx.block_label(merge_idx); + ctx.block().br(&merge_label); + + ctx.current_block = merge_idx; + ctx.block() + .phi(DOUBLE, &[(&cached, &hit_end), (&resolved, &miss_end)]) +} + +/// Consume the exact-u32 prefix established by mode-2 runtime admission. That +/// persistent, mutation-invalidated proof makes `fptoui` defined here. A +/// shared component-length check below still keeps each direct typed-array +/// access within its owning allocation. +fn emit_canonical_u32_guard( + ctx: &mut FnCtx<'_>, + value: &str, + native_u32: Option<&str>, + out_of_bounds_label: &str, + component_bound: Option<&str>, +) -> String { + let canonical = native_u32 + .map(ToOwned::to_owned) + .unwrap_or_else(|| ctx.block().fptoui(DOUBLE, value, I32)); + if let Some(bound) = component_bound { + let in_bounds = ctx.block().icmp_ult(I32, &canonical, bound); + let continue_idx = ctx.new_block("stable_packed.component.in_bounds"); + let continue_label = ctx.block_label(continue_idx); + ctx.block() + .cond_br(&in_bounds, &continue_label, out_of_bounds_label); + ctx.current_block = continue_idx; + } + canonical +} + +fn u32_out_of_bounds_label(fact: &StablePackedLoopFact) -> &str { + fact.u32_out_of_bounds_label + .as_deref() + .unwrap_or(&fact.side_exit_label) +} /// Complete a nested-derived indexed read. The direct arm has already /// consumed the live raw address with no intervening safepoint. On a guard or @@ -841,6 +1319,83 @@ pub(crate) fn has_numeric_index_fact(ctx: &FnCtx<'_>, expr: &Expr) -> bool { }) } +pub(crate) fn has_u32_index_fact(ctx: &FnCtx<'_>, expr: &Expr) -> bool { + let Expr::IndexGet { object, index } = expr else { + return false; + }; + let (Expr::LocalGet(array_id), Expr::LocalGet(counter_id)) = (object.as_ref(), index.as_ref()) + else { + return false; + }; + ctx.stable_packed_loop_facts.iter().rev().any(|fact| { + fact.u32_index_elements + && fact.array_local_id == *array_id + && fact.counter_local_id == *counter_id + }) +} + +pub(crate) fn has_u32_component_bound(ctx: &FnCtx<'_>, expr: &Expr) -> bool { + let Expr::IndexGet { object, index } = expr else { + return false; + }; + let (Expr::LocalGet(array_id), Expr::LocalGet(counter_id)) = (object.as_ref(), index.as_ref()) + else { + return false; + }; + ctx.stable_packed_loop_facts.iter().rev().any(|fact| { + fact.u32_component_bound.is_some() + && fact.array_local_id == *array_id + && fact.counter_local_id == *counter_id + }) +} + +/// Lower an admitted packed entity-id read and return its canonical native +/// u32 bits. Repeated source occurrences share both the guarded read and this +/// conversion through `StablePackedReadCache`. +pub(crate) fn try_lower_u32_index(ctx: &mut FnCtx<'_>, expr: &Expr) -> Option { + if !has_u32_index_fact(ctx, expr) { + return None; + } + let Expr::IndexGet { object, index } = expr else { + return None; + }; + let value = try_lower_index_get(ctx, object, index)?; + let (cache_slot, out_of_bounds_label, component_bound) = ctx + .stable_packed_loop_facts + .iter() + .rev() + .find(|fact| { + matches!( + (object.as_ref(), index.as_ref()), + (Expr::LocalGet(array_id), Expr::LocalGet(counter_id)) + if fact.array_local_id == *array_id + && fact.counter_local_id == *counter_id + ) + }) + .map(|fact| { + ( + fact.repeated_read_cache + .as_ref() + .and_then(|cache| cache.u32_slot.clone()), + fact.u32_out_of_bounds_label + .clone() + .unwrap_or_else(|| fact.side_exit_label.clone()), + fact.u32_component_bound.clone(), + ) + })?; + Some(if let Some(slot) = cache_slot { + ctx.block().load(I32, &slot) + } else { + emit_canonical_u32_guard( + ctx, + &value, + None, + &out_of_bounds_label, + component_bound.as_deref(), + ) + }) +} + /// Refresh a captured receiver at fast-iteration entry. The closure pointer is /// reloaded through its GC root by ordinary `LocalGet` lowering, then the full /// runtime admission rechecks identity, forwarding, layout, descriptors, @@ -864,7 +1419,10 @@ pub(super) fn emit_iteration_guard( &[ (DOUBLE, &receiver), (DOUBLE, &fact.bound), - (I32, if fact.numeric_elements { "1" } else { "0" }), + ( + I32, + required_numeric_mode(fact.numeric_elements, fact.u32_index_elements), + ), (PTR, &fact.descriptor), ], ); @@ -885,7 +1443,7 @@ pub(super) fn emit_iteration_guard( let numeric_access = fact .numeric_elements - .then(|| build_numeric_access(ctx, &fact.descriptor, &live_raw)); + .then(|| build_numeric_access(ctx, &fact.descriptor, &live_raw, fact.u32_index_elements)); if let Some(active) = ctx.stable_packed_loop_facts.last_mut() { active.live_receiver_handle = Some(live_raw); active.numeric_access = numeric_access; @@ -903,6 +1461,17 @@ pub(super) fn lower( let Some(candidate) = match_candidate(ctx, init, condition, update, body) else { return Ok(false); }; + let typed_array_candidate = super::stable_packed_typed_array::find_candidate( + ctx, + body, + candidate.array_id, + candidate.counter_id, + candidate.u32_index_elements, + ); + // The stronger entity-id fact exists solely to feed the guarded column + // view clone. Mode-2 admission establishes or consumes its persistent, + // mutation-invalidated exact-u32 prefix proof. + let u32_index_elements = typed_array_candidate.is_some(); let inserted_counter = if ctx.i32_counter_slots.contains_key(&candidate.counter_id) { false } else { @@ -922,24 +1491,53 @@ pub(super) fn lower( LoopBound::Snapshot(bound_id) => crate::expr::lower_expr(ctx, &Expr::LocalGet(bound_id))?, LoopBound::LiveLength => "-1.0".to_string(), }; - let descriptor = ctx.func.alloca_entry_array(I64, 7); - let guard_args = [ - (DOUBLE, receiver.as_str()), - (DOUBLE, bound_box.as_str()), - (I32, if candidate.numeric_elements { "1" } else { "0" }), - (PTR, descriptor.as_str()), - ]; - let (admitted, admitted_live_raw) = if candidate.capture_index.is_some() { - let live_raw = ctx - .block() - .call(I64, "js_packed_arraylike_loop_guard_live", &guard_args); - (ctx.block().icmp_ne(I64, &live_raw, "0"), Some(live_raw)) - } else { - let guard = ctx - .block() - .call(I32, "js_packed_arraylike_loop_guard", &guard_args); - (ctx.block().icmp_ne(I32, &guard, "0"), None) - }; + let descriptor = ctx + .func + .alloca_entry_array(I64, if u32_index_elements { 11 } else { 7 }); + let (admitted, admitted_live_raw, typed_array_admission) = + if let Some(typed_array_candidate) = typed_array_candidate.as_ref() { + let (admission, live_raw) = super::stable_packed_typed_array::emit_fused_admission( + ctx, + typed_array_candidate, + &receiver, + &bound_box, + &descriptor, + )?; + (admission.guard.clone(), Some(live_raw), Some(admission)) + } else { + let guard_args = [ + (DOUBLE, receiver.as_str()), + (DOUBLE, bound_box.as_str()), + ( + I32, + required_numeric_mode(candidate.numeric_elements, false), + ), + (PTR, descriptor.as_str()), + ]; + if candidate.capture_index.is_some() { + let live_raw = + ctx.block() + .call(I64, "js_packed_arraylike_loop_guard_live", &guard_args); + ( + ctx.block().icmp_ne(I64, &live_raw, "0"), + Some(live_raw), + None, + ) + } else { + let guard = ctx + .block() + .call(I32, "js_packed_arraylike_loop_guard", &guard_args); + (ctx.block().icmp_ne(I32, &guard, "0"), None, None) + } + }; + // The conservative column matcher proves the cloned body call-free, and + // `fast_raw` below reloads the rooted derived receiver after every + // admission helper has returned. Its packed layout therefore stays valid + // for the complete clone. Keep the broader per-read revalidation tier for + // nested generic bodies, but let this clone hoist all descriptor-derived + // bases into its preheader. + let revalidate_before_indexed_read = + candidate.nested_requires_access_revalidation && typed_array_admission.is_none(); // Deliberately left unterminated until the emitted fast clone has been // scanned. The cached receiver below is safe only when no runtime call can // allocate, collect, or revoke an admitted layout while that clone runs. @@ -970,11 +1568,53 @@ pub(super) fn lower( .and(I64, &fast_bits, crate::nanbox::POINTER_MASK_I64) }; let fast_scan_start = ctx.func.num_blocks(); + let installed_typed_array_views = typed_array_admission + .as_ref() + .map(|admission| super::stable_packed_typed_array::install_views(ctx, admission)); let numeric_access = if candidate.numeric_elements { - Some(build_numeric_access(ctx, &descriptor, &fast_raw)) + Some(build_numeric_access( + ctx, + &descriptor, + &fast_raw, + u32_index_elements, + )) } else { None }; + let revalidation_dirty_slot = candidate + .nested_requires_access_revalidation + .then(|| ctx.func.alloca_entry(I1)); + let revalidation_live_raw_slot = candidate + .nested_requires_access_revalidation + .then(|| ctx.func.alloca_entry(I64)); + let repeated_read_cache = candidate + .cache_repeated_index_reads + .then(|| StablePackedReadCache { + valid_slot: ctx.func.alloca_entry(I1), + counter_slot: ctx.func.alloca_entry(I32), + value_slot: ctx.func.alloca_entry(DOUBLE), + u32_slot: u32_index_elements.then(|| ctx.func.alloca_entry(I32)), + has_producer: false, + }); + if let Some(cache) = repeated_read_cache.as_ref() { + ctx.block().store(I1, "0", &cache.valid_slot); + } + if let Some(slot) = revalidation_dirty_slot.as_ref() { + // The admitting guard and the post-guard receiver reload establish a + // clean proof. Calls emitted after this point dirty it at their actual + // control-flow location via LlBlock's call choke points. + ctx.block().store(I1, "0", slot); + ctx.block().store( + I64, + &fast_raw, + revalidation_live_raw_slot + .as_ref() + .expect("nested revalidation raw slot"), + ); + ctx.func + .reg_counter() + .push_stable_packed_revalidation_slot(slot.clone()); + } ctx.stable_packed_loop_facts.push(StablePackedLoopFact { counter_local_id: candidate.counter_id, array_local_id: candidate.array_id, @@ -984,11 +1624,20 @@ pub(super) fn lower( admitted_bound: bound64, live_length_bound: matches!(candidate.bound, LoopBound::LiveLength), revalidate_each_iteration: candidate.capture_index.is_some(), - revalidate_before_indexed_read: candidate.nested_requires_access_revalidation, + revalidate_before_indexed_read, + revalidation_dirty_slot: revalidation_dirty_slot.clone(), + revalidation_live_raw_slot, + repeated_read_cache, live_receiver_handle: Some(fast_raw), numeric_elements: candidate.numeric_elements, + u32_index_elements, + u32_component_bound: installed_typed_array_views + .as_ref() + .map(|installed| installed.common_length.clone()), + u32_out_of_bounds_label: None, numeric_access, derived_locals: std::collections::HashSet::new(), + u32_view_derived_locals: std::collections::HashMap::new(), }); super::loops::lower_for_after_init_with_i32_bound( ctx, @@ -1000,6 +1649,14 @@ pub(super) fn lower( Some((candidate.counter_id, bound_i32)), )?; ctx.stable_packed_loop_facts.pop(); + if let Some(installed) = installed_typed_array_views { + super::stable_packed_typed_array::restore_views(ctx, installed); + } + if let Some(slot) = revalidation_dirty_slot.as_ref() { + ctx.func + .reg_counter() + .pop_stable_packed_revalidation_slot(slot); + } if !ctx.block().is_terminated() { // A call-free clone cannot grow or shrink its receiver, so exhausting // the admitted bound is also the exact live-length loop exit. diff --git a/crates/perry-codegen/src/stmt/stable_packed_typed_array.rs b/crates/perry-codegen/src/stmt/stable_packed_typed_array.rs new file mode 100644 index 0000000000..15d171fe07 --- /dev/null +++ b/crates/perry-codegen/src/stmt/stable_packed_typed_array.rs @@ -0,0 +1,401 @@ +//! Loop-local guarded views for erased ECS-style component columns. +//! +//! Type information is commonly lost at the system boundary: a pair of +//! component columns arrives as `any`, while the inner entity loop repeatedly +//! executes `a[entities[j]]` / `b[entities[j]]`. Per-access dynamic TypedArray +//! dispatch is correct but disproportionately expensive. This module admits a +//! narrow fast clone when runtime evidence proves all of the facts the native +//! buffer-view lowering needs once for the complete loop: +//! +//! * the entity list has the stable packed `u32`-index proof; +//! * two to four erased locals are used only as indexed receivers in the body; +//! * every receiver is an owning `Uint32Array`; and +//! * the receiver addresses are pairwise distinct. +//! +//! Any miss enters the unchanged generic clone. The admitted HIR body is +//! deliberately call/observer-free, so no alias can expose `.buffer` and +//! convert an owning array to a side-table view while its cached data pointer +//! is live. TypedArray headers themselves are tenured and non-moving. + +use std::collections::HashMap; + +use anyhow::Result; +use perry_hir::{Expr, Stmt}; + +use crate::expr::FnCtx; +use crate::native_value::{ + AliasState, BufferElem, BufferIndexUnit, BufferViewPointerState, BufferViewSlot, LengthSource, +}; +use crate::types::{DOUBLE, I1, I32, I64, I8, PTR}; + +const MIN_RECEIVERS: usize = 2; +const MAX_RECEIVERS: usize = 4; + +#[derive(Default)] +struct LocalUses { + total: usize, + receiver: usize, + accesses: usize, +} + +#[derive(Clone)] +pub(super) struct Candidate { + local_ids: Vec, +} + +pub(super) struct Admission { + pub guard: String, + raw_receivers: Vec<(u32, String)>, +} + +pub(super) struct InstalledViews { + previous: Vec<(u32, Option)>, + pub common_length: String, +} + +fn exact_entity_read(expr: &Expr, array_id: u32, counter_id: u32) -> bool { + matches!( + expr, + Expr::IndexGet { object, index } + if matches!(object.as_ref(), Expr::LocalGet(id) if *id == array_id) + && matches!(index.as_ref(), Expr::LocalGet(id) if *id == counter_id) + ) +} + +fn collect_expr_uses(expr: &Expr, uses: &mut HashMap) { + if let Expr::LocalGet(id) = expr { + uses.entry(*id).or_default().total += 1; + } + match expr { + Expr::IndexGet { object, .. } + | Expr::IndexSet { object, .. } + | Expr::IndexUpdate { object, .. } => { + if let Expr::LocalGet(id) = object.as_ref() { + let use_ = uses.entry(*id).or_default(); + use_.receiver += 1; + use_.accesses += 1; + } + } + Expr::PutValueSet { + target, receiver, .. + } => { + if let (Expr::LocalGet(target_id), Expr::LocalGet(receiver_id)) = + (target.as_ref(), receiver.as_ref()) + { + if target_id == receiver_id { + let use_ = uses.entry(*target_id).or_default(); + // The central HIR walker visits both operands. + use_.receiver += 2; + use_.accesses += 1; + } + } + } + Expr::Closure { .. } => return, + _ => {} + } + perry_hir::walker::walk_expr_children(expr, &mut |child| collect_expr_uses(child, uses)); +} + +fn collect_stmt_uses(stmt: &Stmt, uses: &mut HashMap) { + match stmt { + Stmt::Let { + init: Some(expr), .. + } + | Stmt::Expr(expr) + | Stmt::Return(Some(expr)) + | Stmt::Throw(expr) => collect_expr_uses(expr, uses), + Stmt::If { + condition, + then_branch, + else_branch, + } => { + collect_expr_uses(condition, uses); + then_branch + .iter() + .for_each(|stmt| collect_stmt_uses(stmt, uses)); + if let Some(branch) = else_branch { + branch.iter().for_each(|stmt| collect_stmt_uses(stmt, uses)); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { condition, body } => { + collect_expr_uses(condition, uses); + body.iter().for_each(|stmt| collect_stmt_uses(stmt, uses)); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(init) = init { + collect_stmt_uses(init, uses); + } + if let Some(condition) = condition { + collect_expr_uses(condition, uses); + } + if let Some(update) = update { + collect_expr_uses(update, uses); + } + body.iter().for_each(|stmt| collect_stmt_uses(stmt, uses)); + } + Stmt::Labeled { body, .. } => collect_stmt_uses(body, uses), + Stmt::Try { + body, + catch, + finally, + } => { + body.iter().for_each(|stmt| collect_stmt_uses(stmt, uses)); + if let Some(catch) = catch { + catch + .body + .iter() + .for_each(|stmt| collect_stmt_uses(stmt, uses)); + } + if let Some(finally) = finally { + finally + .iter() + .for_each(|stmt| collect_stmt_uses(stmt, uses)); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + collect_expr_uses(discriminant, uses); + for case in cases { + if let Some(test) = case.test.as_ref() { + collect_expr_uses(test, uses); + } + case.body + .iter() + .for_each(|stmt| collect_stmt_uses(stmt, uses)); + } + } + _ => {} + } +} + +/// This is intentionally a whitelist, not a generic "may call" classifier. +/// It proves that no path in the admitted clone can run user code capable of +/// exposing a selected receiver's backing buffer. +fn safe_expr(expr: &Expr, selected: &[u32], entity_array_id: u32, counter_id: u32) -> bool { + match expr { + Expr::Undefined + | Expr::Null + | Expr::Bool(_) + | Expr::Number(_) + | Expr::Integer(_) + | Expr::LocalGet(_) => true, + Expr::IndexGet { object, index } => { + exact_entity_read(expr, entity_array_id, counter_id) + || matches!(object.as_ref(), Expr::LocalGet(id) if selected.contains(id)) + && exact_entity_read(index, entity_array_id, counter_id) + } + Expr::IndexSet { + object, + index, + value, + } => { + matches!(object.as_ref(), Expr::LocalGet(id) if selected.contains(id)) + && exact_entity_read(index, entity_array_id, counter_id) + && safe_expr(value, selected, entity_array_id, counter_id) + } + Expr::PutValueSet { + target, + key, + value, + receiver, + .. + } => { + matches!( + (target.as_ref(), receiver.as_ref()), + (Expr::LocalGet(target_id), Expr::LocalGet(receiver_id)) + if target_id == receiver_id && selected.contains(target_id) + ) && exact_entity_read(key, entity_array_id, counter_id) + && safe_expr(value, selected, entity_array_id, counter_id) + } + _ => false, + } +} + +fn safe_body(body: &[Stmt], selected: &[u32], entity_array_id: u32, counter_id: u32) -> bool { + body.iter().all(|stmt| match stmt { + Stmt::Let { + init: Some(expr), .. + } + | Stmt::Expr(expr) => safe_expr(expr, selected, entity_array_id, counter_id), + _ => false, + }) +} + +pub(super) fn find_candidate( + ctx: &FnCtx<'_>, + body: &[Stmt], + entity_array_id: u32, + counter_id: u32, + u32_index_elements: bool, +) -> Option { + if !u32_index_elements || ctx.disable_buffer_fast_path { + return None; + } + let mut uses = HashMap::new(); + body.iter() + .for_each(|stmt| collect_stmt_uses(stmt, &mut uses)); + let mut local_ids: Vec = uses + .into_iter() + .filter_map(|(id, use_)| { + if id == entity_array_id + || id == counter_id + || use_.accesses < 2 + || use_.receiver != use_.total + || !ctx.locals.contains_key(&id) + || ctx.boxed_vars.contains(&id) + || ctx.closure_captures.contains_key(&id) + || ctx.reassigned_locals.contains(&id) + || ctx.buffer_view_slots.contains_key(&id) + || !matches!( + crate::type_analysis::static_type_of(ctx, &Expr::LocalGet(id)), + None | Some(perry_hir::types::Type::Any) + | Some(perry_hir::types::Type::Unknown) + ) + { + return None; + } + Some(id) + }) + .collect(); + local_ids.sort_unstable(); + if !(MIN_RECEIVERS..=MAX_RECEIVERS).contains(&local_ids.len()) + || !safe_body(body, &local_ids, entity_array_id, counter_id) + { + return None; + } + Some(Candidate { local_ids }) +} + +pub(super) fn emit_fused_admission( + ctx: &mut FnCtx<'_>, + candidate: &Candidate, + source_receiver: &str, + bound: &str, + descriptor: &str, +) -> Result<(Admission, String)> { + let mut columns = Vec::with_capacity(MAX_RECEIVERS); + for id in &candidate.local_ids { + columns.push(crate::expr::lower_expr(ctx, &Expr::LocalGet(*id))?); + } + while columns.len() < MAX_RECEIVERS { + columns.push("0.0".to_string()); + } + let live_raw = ctx.block().call( + I64, + "js_packed_ecs_u32_loop_guard", + &[ + (DOUBLE, source_receiver), + (DOUBLE, bound), + (DOUBLE, &columns[0]), + (DOUBLE, &columns[1]), + (DOUBLE, &columns[2]), + (DOUBLE, &columns[3]), + (I32, &candidate.local_ids.len().to_string()), + (PTR, descriptor), + ], + ); + let guard = ctx.block().icmp_ne(I64, &live_raw, "0"); + let raw_receivers = candidate + .local_ids + .iter() + .enumerate() + .map(|(index, id)| { + let slot = ctx + .block() + .gep(I64, descriptor, &[(I64, &(7 + index).to_string())]); + (*id, ctx.block().load(I64, &slot)) + }) + .collect(); + Ok(( + Admission { + guard, + raw_receivers, + }, + live_raw, + )) +} + +fn reserve_alias_scope(ctx: &mut FnCtx<'_>, data_slot: &str) -> u32 { + let scope_idx = ctx.buffer_alias_base + ctx.buffer_data_slots.len() as u32; + // Loop-local views are removed before the generic clone is lowered, but + // module metadata is emitted from the final map length. Retain a synthetic + // unreachable key so scope ids stay unique and every metadata reference is + // declared. Real HIR LocalIds cannot collide because we probe all maps. + let mut reservation = u32::MAX; + while ctx.locals.contains_key(&reservation) + || ctx.module_globals.contains_key(&reservation) + || ctx.buffer_data_slots.contains_key(&reservation) + || ctx.buffer_view_slots.contains_key(&reservation) + { + reservation = reservation.wrapping_sub(1); + } + ctx.buffer_data_slots + .insert(reservation, (data_slot.to_string(), scope_idx)); + scope_idx +} + +pub(super) fn install_views(ctx: &mut FnCtx<'_>, admission: &Admission) -> InstalledViews { + let mut previous = Vec::with_capacity(admission.raw_receivers.len()); + let mut common_length: Option = None; + for (id, raw) in &admission.raw_receivers { + let (data_slot, length_slot, length) = { + let header = ctx.block().inttoptr(I64, raw); + let length = ctx.block().load(I32, &header); + let data = ctx.block().gep(I8, &header, &[(I32, "16")]); + let data_slot = ctx.func.alloca_entry(PTR); + let length_slot = ctx.func.alloca_entry(I32); + ctx.block().store(PTR, &data, &data_slot); + ctx.block().store(I32, &length, &length_slot); + (data_slot, length_slot, length) + }; + common_length = Some(if let Some(current) = common_length { + let shorter = ctx.block().icmp_ult(I32, &length, ¤t); + ctx.block().select(I1, &shorter, I32, &length, ¤t) + } else { + length.clone() + }); + let scope_idx = reserve_alias_scope(ctx, &data_slot); + let old = ctx.buffer_view_slots.insert( + *id, + BufferViewSlot { + data_slot, + length_slot: Some(length_slot), + scope_idx: Some(scope_idx), + elem: BufferElem::U32, + element_width_bytes: 4, + index_unit: BufferIndexUnit::Element, + view_byte_offset: Some(0), + length_offset_from_data: -16, + alias: AliasState::NoAliasGuarded { + guard_id: "stable_packed_u32_columns".to_string(), + }, + length_source: Some(LengthSource::Unknown), + native_owned: None, + pointer_state: BufferViewPointerState::Stable, + storage_inline_proven: true, + }, + ); + previous.push((*id, old)); + } + InstalledViews { + previous, + common_length: common_length.expect("typed-array candidate has at least two receivers"), + } +} + +pub(super) fn restore_views(ctx: &mut FnCtx<'_>, installed: InstalledViews) { + for (id, old) in installed.previous { + if let Some(old) = old { + ctx.buffer_view_slots.insert(id, old); + } else { + ctx.buffer_view_slots.remove(&id); + } + } +} diff --git a/crates/perry-runtime/src/array/generic.rs b/crates/perry-runtime/src/array/generic.rs index d6fe826b76..4d987c5652 100644 --- a/crates/perry-runtime/src/array/generic.rs +++ b/crates/perry-runtime/src/array/generic.rs @@ -1145,8 +1145,9 @@ pub extern "C" fn js_arraylike_includes(recv: f64, value: f64, from: f64, has_fr } // --------------------------------------------------------------------------- -// at / join / slice — no callback identity concerns; materialise where it -// keeps the implementation simple (slice/join build fresh results anyway). +// at / join / slice — no callback identity concerns. Join materialises its +// receiver; slice copies only its selected interval so oversized array-like +// lengths can be validated before allocation or indexed reads. // --------------------------------------------------------------------------- #[no_mangle] @@ -1164,8 +1165,8 @@ pub extern "C" fn js_arraylike_at(recv: f64, index: f64) -> f64 { al_get(recv, k) } -/// Materialise `recv` into a fresh real array (holes preserved as `TAG_HOLE`), -/// for the delegating `join` / `slice` paths. +/// Materialise `recv` into a fresh real array (holes preserved as `TAG_HOLE`) +/// for the delegating `join` path. fn materialize(recv: f64) -> *mut ArrayHeader { let len = al_length(recv); let arr = js_array_alloc_with_length(len.max(0) as u32); @@ -1205,9 +1206,9 @@ pub extern "C" fn js_arraylike_slice( end: f64, has_end: i32, ) -> f64 { - let recv = to_object(recv); - let arr = materialize(recv); - let len = unsafe { (*arr).length as i64 }; + let scope = crate::gc::RuntimeHandleScope::new(); + let recv_h = scope.root_nanbox_f64(to_object(recv)); + let len = al_length(recv_h.get_nanbox_f64()); let s = if has_start == 0 { 0 } else { @@ -1224,8 +1225,36 @@ pub extern "C" fn js_arraylike_slice( } else { clamp_index(end, len) }; - let result = js_array_slice(arr, s as i32, e as i32); - nanbox_arr(result) + let count = e.saturating_sub(s); + + // ArraySpeciesCreate(O, count) ultimately performs ArrayCreate(count), + // which rejects lengths above the Array index limit before consulting any + // source index. Do not narrow the result length to u32 (or materialise the + // entire receiver) first: an array-like may legitimately have a ToLength + // value up to 2^53 - 1. (test262 slice/*-invalid-len) + if count > u32::MAX as i64 { + crate::array::array_length_range_error(); + } + + let result_h = scope.root_raw_mut_ptr(js_array_alloc_with_length(count.max(0) as u32)); + let value_h = scope.root_nanbox_f64(undef()); + for n in 0..count { + let k = s + n; + if !al_has(recv_h.get_nanbox_f64(), k) { + continue; // preserve holes + } + value_h.set_nanbox_f64(al_get(recv_h.get_nanbox_f64(), k)); + let value = value_h.get_nanbox_f64(); + result_h.with_mut_ptr::(|result| unsafe { + let elems = (result as *mut u8).add(std::mem::size_of::()) as *mut f64; + // GC_STORE_AUDIT(BARRIERED): note_array_slot below re-stores this + // slot with the write barrier after the direct dense write. + ptr::write(elems.add(n as usize), value); + note_array_slot(result, n as usize, value.to_bits()); + }); + } + // Scoped argument to a non-allocating operation; see js_arraylike_map. + result_h.with_mut_ptr::(nanbox_arr) } /// ECMA-262 relative-index clamp used by `slice` (negative counts from the end, diff --git a/crates/perry-runtime/src/array/subclass.rs b/crates/perry-runtime/src/array/subclass.rs index 9ec0ef3ee5..9c5d60197a 100644 --- a/crates/perry-runtime/src/array/subclass.rs +++ b/crates/perry-runtime/src/array/subclass.rs @@ -21,14 +21,18 @@ use crate::value::JSValue; // // bit 0 existing custom-[[Prototype]] flag // bit 1 payload valid -// bits 8..31 verified numeric prefix bound (24 bits, max 16,000,000) +// bit 2 compact nonnegative-int entity proof (mode 2) +// bits 8..31 verified prefix bound (24 bits, max 16,000,000) // bits 32..63 exact semantic ShapeId const PACKED_NUMERIC_META_VALID: u64 = 1 << 1; +const PACKED_NUMERIC_META_U32: u64 = 1 << 2; const PACKED_NUMERIC_META_BOUND_SHIFT: u32 = 8; const PACKED_NUMERIC_META_BOUND_MASK: u64 = 0x00FF_FFFF << PACKED_NUMERIC_META_BOUND_SHIFT; const PACKED_NUMERIC_META_SHAPE_MASK: u64 = 0xFFFF_FFFF_0000_0000; -const PACKED_NUMERIC_META_MASK: u64 = - PACKED_NUMERIC_META_VALID | PACKED_NUMERIC_META_BOUND_MASK | PACKED_NUMERIC_META_SHAPE_MASK; +const PACKED_NUMERIC_META_MASK: u64 = PACKED_NUMERIC_META_VALID + | PACKED_NUMERIC_META_U32 + | PACKED_NUMERIC_META_BOUND_MASK + | PACKED_NUMERIC_META_SHAPE_MASK; // #8655: Array-subclass instances use ordinary ObjectHeader property slots, // but their hot numeric reads have a much stronger invariant than a generic @@ -317,6 +321,7 @@ unsafe fn subclass_numeric_prefix_is_proven( obj: *const ObjectHeader, shape_id: u32, bound: u32, + require_u32: bool, ) -> bool { let Some(header) = crate::value::addr_class::try_read_gc_header(obj as usize) else { return false; @@ -334,8 +339,13 @@ unsafe fn subclass_numeric_prefix_is_proven( let payload_valid = flags & PACKED_NUMERIC_META_VALID != 0; let proven_bound = ((flags & PACKED_NUMERIC_META_BOUND_MASK) >> PACKED_NUMERIC_META_BOUND_SHIFT) as u32; + let exact_u32 = flags & PACKED_NUMERIC_META_U32 != 0; let proven_shape = (flags >> 32) as u32; - if payload_valid && proven_shape == shape_id && proven_bound >= bound { + if payload_valid + && proven_shape == shape_id + && proven_bound >= bound + && require_u32 == exact_u32 + { return true; } clear_packed_subclass_numeric_proof(obj as *mut ObjectHeader); @@ -347,6 +357,7 @@ unsafe fn publish_subclass_numeric_prefix( obj: *const ObjectHeader, shape_id: u32, bound: u32, + exact_u32: bool, ) -> bool { let meta = (*obj).meta; if meta.is_null() || bound > 16_000_000 { @@ -355,6 +366,11 @@ unsafe fn publish_subclass_numeric_prefix( let flags = (*meta).flags; (*meta).flags = (flags & !PACKED_NUMERIC_META_MASK) | PACKED_NUMERIC_META_VALID + | if exact_u32 { + PACKED_NUMERIC_META_U32 + } else { + 0 + } | (u64::from(bound) << PACKED_NUMERIC_META_BOUND_SHIFT) | (u64::from(shape_id) << 32); let Some(header) = crate::value::addr_class::try_read_gc_header(obj as usize) else { @@ -374,12 +390,13 @@ unsafe fn ensure_subclass_numeric_prefix( obj: *const ObjectHeader, layout: DenseSubclassLayout, bound: u32, + require_u32: bool, ) -> bool { if bound == 0 { return true; } let shape_id = (*obj).parent_class_id; - if subclass_numeric_prefix_is_proven(obj, shape_id, bound) { + if subclass_numeric_prefix_is_proven(obj, shape_id, bound, require_u32) { return true; } for index in 0..bound { @@ -406,7 +423,7 @@ unsafe fn ensure_subclass_numeric_prefix( .add(slot as usize) }; let value = JSValue::from_bits(ptr::read(value_ptr)); - if value.is_int32() { + let number = if value.is_int32() { // `push(i)` commonly stores Perry's compact INT32 Number tag. The // direct clone consumes raw doubles, so normalize that Number to // its representation-equivalent f64 bits during the one-time @@ -415,12 +432,39 @@ unsafe fn ensure_subclass_numeric_prefix( // barrier nor a layout downgrade. // GC_STORE_AUDIT(POINTER_FREE): canonical raw-f64 Number bits // replace compact int32 Number bits in an already numeric slot. - ptr::write(value_ptr, (value.as_int32() as f64).to_bits()); + let integer = value.as_int32(); + let number = integer as f64; + if !require_u32 { + ptr::write(value_ptr, number.to_bits()); + } + number } else if !value.is_number() { return false; + } else { + value.as_number() + }; + if require_u32 { + // ECS entity ids in this tier are normalized to Perry's ordinary + // compact INT32 Number representation. Generic reads still + // observe the same JS Number, while generated component access + // can consume the low native lane without an f64 conversion. + // Values outside the nonnegative i31 subset retain the generic + // loop; no public behavior is narrowed. + if !number.is_finite() + || number < 0.0 + || number > i32::MAX as f64 + || number.fract() != 0.0 + { + return false; + } + if !value.is_int32() { + // GC_STORE_AUDIT(POINTER_FREE): compact Number bits replace + // raw-f64 Number bits in an already numeric slot. + ptr::write(value_ptr, JSValue::int32(number as i32).bits()); + } } } - publish_subclass_numeric_prefix(obj, shape_id, bound) + publish_subclass_numeric_prefix(obj, shape_id, bound, require_u32) } #[inline] @@ -650,6 +694,13 @@ fn packed_arraylike_loop_guard( if bound > length || length > capacity || capacity > 16_000_000 { return None; } + // Mode 2 is the stronger ECS entity-id contract. Plain Arrays do not + // carry the per-prefix payload needed to distinguish an exact-u32 + // proof from their whole-array raw-f64 bit, so retain the generic + // clone for them. + if require_numeric >= 2 { + return None; + } if require_numeric != 0 { // The raw-f64 invariant is an O(1) GcHeader bit after its first // self-healing scan, and every nonnumeric Array write already @@ -694,8 +745,19 @@ fn packed_arraylike_loop_guard( if bound > length || bound > layout.dense_prefix_len || length > 16_000_000 { return None; } + if require_numeric >= 2 { + // The direct ECS clone uses one preheader base. Reject the uncommon + // layout whose admitted prefix straddles inline object fields and the + // object-owned spill array; the unchanged generic clone handles it. + let Some(end_slot) = layout.element_base.checked_add(bound) else { + return None; + }; + if layout.element_base < layout.live_inline_slots && end_slot > layout.live_inline_slots { + return None; + } + } if require_numeric != 0 { - if !unsafe { ensure_subclass_numeric_prefix(object, layout, bound) } { + if !unsafe { ensure_subclass_numeric_prefix(object, layout, bound, require_numeric >= 2) } { return None; } } @@ -744,6 +806,58 @@ pub extern "C" fn js_packed_arraylike_loop_guard_live( .unwrap_or(0) } +/// Fused admission for the call-free ECS swap clone. The source layout and +/// exact-u32 prefix are validated by the packed-loop guard, while two to four +/// erased component columns must be pairwise-distinct owning Uint32Arrays. +/// The first seven output words retain the ordinary source descriptor; words +/// 7..10 receive up to four stable component header addresses. A zero return +/// leaves the complete operation to the unchanged generic loop. +#[no_mangle] +#[inline(never)] +pub extern "C" fn js_packed_ecs_u32_loop_guard( + receiver: f64, + bound: f64, + column0: f64, + column1: f64, + column2: f64, + column3: f64, + column_count: i32, + out: *mut u64, +) -> i64 { + if out.is_null() || !(2..=4).contains(&column_count) { + return 0; + } + let Some((_, live_raw)) = packed_arraylike_loop_guard(receiver, bound, 2, out) else { + return 0; + }; + let columns = [column0, column1, column2, column3]; + let mut addresses = [0usize; 4]; + let mut common_length = None; + for index in 0..column_count as usize { + let address = crate::typedarray::inline_u32_addr(columns[index]); + if address == 0 || addresses[..index].contains(&address) { + return 0; + } + let length = unsafe { (*(address as *const crate::typedarray::TypedArrayHeader)).length }; + if common_length.is_some_and(|common| common != length) { + return 0; + } + common_length = Some(length); + addresses[index] = address; + } + unsafe { + for (index, address) in addresses + .iter() + .copied() + .take(column_count as usize) + .enumerate() + { + out.add(7 + index).write(address as u64); + } + } + live_raw as i64 +} + /// Revalidate a receiver against facts published by a successful complete /// loop admission. Unlike the admitting guard, this path never rediscovers or /// republishes the dense layout: exact class/ShapeId and header checks make the @@ -843,6 +957,7 @@ pub extern "C" fn js_packed_arraylike_loop_revalidate_live( || (!live_length_bound && length < admitted_bound) || length > capacity || capacity > 16_000_000 + || require_numeric >= 2 || (require_numeric != 0 && header._reserved & crate::gc::GC_ARRAY_RAW_F64_LAYOUT == 0) { return 0; @@ -881,7 +996,12 @@ pub extern "C" fn js_packed_arraylike_loop_revalidate_live( || (!live_length_bound && length < admitted_bound) || (require_numeric != 0 && !unsafe { - subclass_numeric_prefix_is_proven(object, (*object).parent_class_id, admitted_bound) + subclass_numeric_prefix_is_proven( + object, + (*object).parent_class_id, + admitted_bound, + require_numeric >= 2, + ) }) { return 0; @@ -950,7 +1070,12 @@ fn revalidate_admitted_subclass_live( || (bound != -1.0 && length < admitted_bound) || (require_numeric != 0 && !unsafe { - subclass_numeric_prefix_is_proven(object, (*object).parent_class_id, admitted_bound) + subclass_numeric_prefix_is_proven( + object, + (*object).parent_class_id, + admitted_bound, + require_numeric >= 2, + ) }) { return 0; diff --git a/crates/perry-runtime/src/array/subclass_tests.rs b/crates/perry-runtime/src/array/subclass_tests.rs index 84083167f4..2b67a7ad5e 100644 --- a/crates/perry-runtime/src/array/subclass_tests.rs +++ b/crates/perry-runtime/src/array/subclass_tests.rs @@ -20,7 +20,7 @@ use super::subclass::{ array_object_receiver, array_subclass_fast_index_get, array_subclass_fast_length, is_array_subclass_class_id, js_packed_arraylike_index_get, js_packed_arraylike_loop_guard, - raw_receiver_is_heap_object, + js_packed_ecs_u32_loop_guard, raw_receiver_is_heap_object, }; use crate::array::{clean_arr_ptr, js_array_alloc, ArrayHeader}; use crate::object::{js_object_alloc, ObjectHeader}; @@ -265,6 +265,111 @@ fn packed_numeric_proof_is_retired_by_sso_index_overwrite() { ); } +#[test] +fn fused_ecs_guard_requires_distinct_owning_u32_columns_and_exact_entity_ids() { + let class_id = 0x0074_8691; + crate::object::js_register_class_parent(class_id, CLASS_ID_ARRAY); + let obj = js_object_alloc(class_id, 2); + assert!(!obj.is_null()); + let receiver = crate::value::js_nanbox_pointer(obj as i64); + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver_h = scope.root_nanbox_f64(receiver); + crate::node_stream::js_array_subclass_init(receiver_h.get_nanbox_f64(), 0.0); + for (index, value) in [3.0, 12.0, 7.0].into_iter().enumerate() { + let live_raw = receiver_h.get_nanbox_f64().to_bits() & 0x0000_FFFF_FFFF_FFFF; + crate::object::js_object_set_index_polymorphic(live_raw as i64, index as f64, value); + } + + let left = + crate::typedarray::js_typed_array_new_empty(crate::typedarray::KIND_UINT32 as i32, 16); + let right = + crate::typedarray::js_typed_array_new_empty(crate::typedarray::KIND_UINT32 as i32, 16); + let wrong_kind = + crate::typedarray::js_typed_array_new_empty(crate::typedarray::KIND_INT32 as i32, 16); + let short = + crate::typedarray::js_typed_array_new_empty(crate::typedarray::KIND_UINT32 as i32, 8); + let left_value = crate::value::js_nanbox_pointer(left as i64); + let right_value = crate::value::js_nanbox_pointer(right as i64); + let wrong_value = crate::value::js_nanbox_pointer(wrong_kind as i64); + let short_value = crate::value::js_nanbox_pointer(short as i64); + let mut facts = [0u64; 11]; + + assert_ne!( + js_packed_ecs_u32_loop_guard( + receiver_h.get_nanbox_f64(), + 3.0, + left_value, + right_value, + 0.0, + 0.0, + 2, + facts.as_mut_ptr(), + ), + 0 + ); + assert_eq!(facts[7], left as u64); + assert_eq!(facts[8], right as u64); + assert_eq!( + js_packed_ecs_u32_loop_guard( + receiver_h.get_nanbox_f64(), + 3.0, + left_value, + left_value, + 0.0, + 0.0, + 2, + facts.as_mut_ptr(), + ), + 0, + "aliased component columns must retain generic assignment semantics" + ); + assert_eq!( + js_packed_ecs_u32_loop_guard( + receiver_h.get_nanbox_f64(), + 3.0, + left_value, + wrong_value, + 0.0, + 0.0, + 2, + facts.as_mut_ptr(), + ), + 0, + "non-Uint32 component columns must not borrow the direct clone" + ); + assert_eq!( + js_packed_ecs_u32_loop_guard( + receiver_h.get_nanbox_f64(), + 3.0, + left_value, + short_value, + 0.0, + 0.0, + 2, + facts.as_mut_ptr(), + ), + 0, + "unequal column lengths need per-column out-of-bounds semantics" + ); + + let live_raw = receiver_h.get_nanbox_f64().to_bits() & 0x0000_FFFF_FFFF_FFFF; + crate::object::js_object_set_index_polymorphic(live_raw as i64, 1.0, 12.5); + assert_eq!( + js_packed_ecs_u32_loop_guard( + receiver_h.get_nanbox_f64(), + 3.0, + left_value, + right_value, + 0.0, + 0.0, + 2, + facts.as_mut_ptr(), + ), + 0, + "a fractional entity id must revoke the exact-u32 source proof" + ); +} + #[test] fn dense_array_subclass_guard_rejects_other_object_brands() { let obj = js_object_alloc(0x0074_8656, 2); diff --git a/crates/perry-runtime/src/object/global_this/array_error.rs b/crates/perry-runtime/src/object/global_this/array_error.rs index 89ae1e42af..09e517a665 100644 --- a/crates/perry-runtime/src/object/global_this/array_error.rs +++ b/crates/perry-runtime/src/object/global_this/array_error.rs @@ -581,7 +581,8 @@ pub(crate) extern "C" fn function_prototype_to_string_thunk( /// Thunk for `Array.prototype.slice` exposed as a real callable closure /// value. Reads the array receiver from `IMPLICIT_THIS` (set by /// `Function.prototype.call`/`.apply`'s runtime arm in -/// `js_native_call_method`) and forwards to the shared slice-value helper. +/// `js_native_call_method`) and forwards ordinary array-like objects to the +/// generic engine or real arrays to the shared dense slice-value helper. /// /// Coerces start/end through the shared array slice helper, with /// `undefined` mapping to `0` for start and end-of-array for end — matching @@ -619,6 +620,16 @@ pub(crate) extern "C" fn array_prototype_slice_thunk( if arr_ptr.is_null() { return f64::from_bits(crate::value::TAG_UNDEFINED); } + // A borrowed builtin (`obj.slice = Array.prototype.slice; obj.slice()`) + // reaches this thunk rather than the HIR ArrayLikeMethod path. Keep the + // original object intact so LengthOfArrayLike and the result-length guard + // run before indexed reads; normalizing it would first materialize the + // entire receiver and narrow a length above u32::MAX. Real arrays, + // arguments objects, and typed arrays retain the species-aware dense path + // below. + if let Some(recv) = crate::array::plain_object_value(arr_ptr) { + return crate::array::js_arraylike_slice(recv, start_val, 1, end_val, 1); + } let result = unsafe { if let Some(arr) = crate::object::arguments_object_to_array(arr_ptr as *const crate::object::ObjectHeader) diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index df05d1fb87..985c9c7902 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -1529,8 +1529,9 @@ pub struct ObjectMeta { /// `accessor_descriptors` table twin of `attr_key_bits`. pub accessor_key_bits: u64, /// Object-only state and compact scalar proof payloads. Bit 0 is the - /// custom-prototype flag. #8690 reserves bit 1 plus bits 8..63 for the - /// packed Array-subclass numeric-prefix proof (verified bound + ShapeId); + /// custom-prototype flag. #8690 reserves bits 1..2 and 8..63 for the + /// packed Array-subclass numeric-prefix proof (kind, verified bound, and + /// ShapeId); /// its address-reuse-safe authority is a type-specific GcHeader bit. /// In particular, GcHeader bit 12 is `GC_OBJ_TYPED_LAYOUT_INTACT`, so /// using that word for prototype divergence made every typed-layout diff --git a/crates/perry-runtime/src/typedarray/mod.rs b/crates/perry-runtime/src/typedarray/mod.rs index aa419894b2..d2af56b85a 100644 --- a/crates/perry-runtime/src/typedarray/mod.rs +++ b/crates/perry-runtime/src/typedarray/mod.rs @@ -585,6 +585,27 @@ pub extern "C" fn js_typed_array_masked_window_data_ptr(receiver: f64) -> i64 { data_ptr(addr as *const TypedArrayHeader) as i64 } +/// One-time loop admission primitive for erased ECS component columns. Return +/// the stable owning-header address only for an exact inline `Uint32Array`. +/// Consult the authoritative registry instead of the tiny direct-mapped kind +/// cache: sibling columns can collide there, which is harmless for individual +/// accesses but must not make a whole-loop proof spuriously fail forever. +#[inline] +pub(crate) fn inline_u32_addr(receiver: f64) -> usize { + let value = crate::value::JSValue::from_bits(receiver.to_bits()); + if !value.is_pointer() { + return 0; + } + let addr = value.as_pointer::() as usize; + if lookup_typed_array_kind(addr) != Some(KIND_UINT32) + || crate::native_arena::is_native_typed_view(addr as *const TypedArrayHeader) + || crate::typedarray_view::view_meta_of(addr).is_some() + { + return 0; + } + addr +} + #[inline] pub(crate) fn data_ptr_mut(ta: *mut TypedArrayHeader) -> *mut u8 { unsafe { diff --git a/crates/perry/tests/issue_5898_array_slice_invalid_length.rs b/crates/perry/tests/issue_5898_array_slice_invalid_length.rs new file mode 100644 index 0000000000..a74184b4c3 --- /dev/null +++ b/crates/perry/tests/issue_5898_array_slice_invalid_length.rs @@ -0,0 +1,126 @@ +//! Regression coverage for the `Array.prototype.slice` invalid-length +//! subcluster in #5898. Generic slice receivers may have a `ToLength` above +//! the Array length limit; the result length must be rejected before indexed +//! reads, without narrowing or trying to materialise the full receiver. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +#[test] +fn generic_slice_rejects_oversized_results_before_index_access() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + let runtime_dir = perry_bin() + .parent() + .expect("perry binary directory") + .to_path_buf(); + std::fs::write( + &entry, + r#" +let plainIndexReads = 0; +const plain: any = { length: 2 ** 32 }; +Object.defineProperty(plain, "0", { + get() { + plainIndexReads++; + return 1; + } +}); +try { + Array.prototype.slice.call(plain); + console.log("plain no throw"); +} catch (error) { + console.log("plain", error instanceof RangeError, plainIndexReads); +} + +const aliased: any = { length: 2 ** 32 }; +aliased.slice = Array.prototype.slice; +try { + aliased.slice(0, 2 ** 32); + console.log("aliased no throw"); +} catch (error) { + console.log("aliased", error instanceof RangeError); +} + +let proxyLengthReads = 0; +let proxyIndexReads = 0; +let proxyWrites = 0; +const proxy = new Proxy([], { + get(target: any, key: any, receiver: any) { + if (key === "length") { + proxyLengthReads++; + return 2 ** 32; + } + proxyIndexReads++; + return Reflect.get(target, key, receiver); + }, + set(target: any, key: any, value: any, receiver: any) { + proxyWrites++; + return Reflect.set(target, key, value, receiver); + } +}); +try { + Array.prototype.slice.call(proxy, 0, 2 ** 32); + console.log("proxy no throw"); +} catch (error) { + console.log( + "proxy", + error instanceof RangeError, + proxyLengthReads, + proxyIndexReads, + proxyWrites + ); +} + +// A huge array-like is valid when the selected interval itself is small. +const tail: any = { length: 2 ** 32 + 1 }; +tail[2 ** 32] = "last"; +const selected = Array.prototype.slice.call(tail, -1); +console.log("tail", selected.length, selected[0]); +"#, + ) + .expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .env("PERRY_LIB_DIR", &runtime_dir) + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .env("PERRY_RS4GC", "0") + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .current_dir(dir.path()) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + concat!( + "plain true 0\n", + "aliased true\n", + "proxy true 1 0 0\n", + "tail 1 last\n" + ) + ); +} diff --git a/crates/perry/tests/issue_8773_closure_capture_packed_loops.rs b/crates/perry/tests/issue_8773_closure_capture_packed_loops.rs index 919a0024f2..f570e5a28b 100644 --- a/crates/perry/tests/issue_8773_closure_capture_packed_loops.rs +++ b/crates/perry/tests/issue_8773_closure_capture_packed_loops.rs @@ -183,6 +183,29 @@ console.log(checksum); ); assert!(ir.contains("stable_packed.iteration.capture_valid")); assert!(ir.contains("call i64 @js_packed_arraylike_loop_guard_live(")); + let clean_read_blocks = named_blocks(&ir, &["stable_packed.indexed_read.proof_clean"]); + let dirty_read_blocks = named_blocks(&ir, &["stable_packed.indexed_read.proof_dirty"]); + assert!( + !clean_read_blocks.is_empty() + && !clean_read_blocks.contains("js_packed_arraylike_loop_revalidate_live"), + "the clean nested-read path must retain its proof without a runtime call\n{clean_read_blocks}" + ); + assert!( + dirty_read_blocks.contains("js_packed_arraylike_loop_revalidate_live"), + "a path dirtied by a preceding call must retain exact revalidation\n{dirty_read_blocks}" + ); + let cache_hit_blocks = named_blocks(&ir, &["stable_packed.indexed_read.cache_hit"]); + assert!( + !cache_hit_blocks.is_empty() + && !cache_hit_blocks.contains("js_packed_arraylike_loop_revalidate_live") + && !cache_hit_blocks.contains("js_packed_arraylike_index_get"), + "a same-counter cache hit must be a call-free exact-value load\n{cache_hit_blocks}" + ); + assert!( + ir.contains("packed_index.generic_fallback") + && ir.contains("packed_index.revalidated_merge"), + "a failed nested proof must branch to the exact-source generic read and rejoin" + ); let fast_blocks = named_blocks(&ir, &["stable_packed", "for.stable_packed_fast"]); assert!( @@ -203,6 +226,8 @@ console.log(checksum); "candidate_storage=closure_capture_slot", "revalidation=each_iteration_capture_reload", "candidate_origin=guarded_outer_index_read", + "nested_read_miss=generic_read_without_iteration_replay", + "same_counter_read_cache=call_invalidated", "guard_identity=stable_packed_arraylike:", "fallback_identity=stable_packed_arraylike:", ] { diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index faf7ffaa83..8438e00821 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -186,6 +186,7 @@ - [Local binding type evidence](internals/local-binding-type-evidence.md) - [Incremental GC step bounds](internals/gc-step-bounds.md) - [RFC: rooting by construction](internals/rfc-rooting-by-construction.md) +- [Node-API host design](internals/node-api-host.md) # Contributing diff --git a/docs/src/internals/node-api-host.md b/docs/src/internals/node-api-host.md new file mode 100644 index 0000000000..a7063d8ad6 --- /dev/null +++ b/docs/src/internals/node-api-host.md @@ -0,0 +1,591 @@ +# Node-API host design + +Status: design contract for [#8523](https://github.com/PerryTS/perry/issues/8523). +The implementation is deliberately staged behind the completed `bun:ffi` +callback work in #6562. This document fixes the representation, lifetime, ABI, +loader, and shipping decisions before the first `napi_*` symbol is exported. + +The host lets a Perry executable load a prebuilt Node-API (`.node`) addon +without embedding Node, V8, JavaScriptCore, or another JavaScript engine. It is +an opt-in compatibility route for the long tail of addons. A package with a +Perry facade remains on that facade: the host never supersedes a +`well_known_bindings.toml` entry. + +## Decisions at a glance + +| Area | Decision | +|---|---| +| Advertised API | Node-API version 8 | +| `napi_env` | One environment per Perry agent/realm, owned by its JavaScript thread | +| `napi_value` | Opaque host token containing an index and generation for an environment-local handle slot; never a Perry heap address | +| Handle roots | Open handle scopes are mutable GC roots and are rewritten after evacuation | +| `napi_ref` | Strong references root their value; zero-count references use Perry's existing weak-target machinery | +| Finalizers | Address-keyed object metadata is rekeyed on moves and death-pruned after marking; callbacks are queued and run on the owning main thread outside GC | +| Exceptions | Perry throws are trapped inside the host, stored in the environment, and returned as `napi_pending_exception`; no unwind crosses addon code | +| Off-thread API | Only TSFN call/acquire/release operations are legal; every other operation verifies the owning thread | +| Module entry | Prefer `napi_register_module_v1`; support constructor-time `napi_module_register` for legacy addons | +| Unsupported ABI | NAN, V8, direct `uv_*`, and mobile targets are rejected before initialization | +| Shipping | Relocatable sidecar directory beside the executable; no extract-at-first-run path | +| Policy | Exact package-name allowlist in `package.json` under `perry.nativeAddons` | +| Size gate | No addon in the graph means no host archive references, no exported Node-API symbols, and a zero-byte executable delta | + +Version 8 is the baseline selected by Node's own v22 headers when an addon does +not request a newer version. It includes BigInt, dates, detach, type tags, +async cleanup, and the complete TSFN surface needed by current napi-rs and +node-addon-api packages, while avoiding a false claim for the version 9 and 10 +extras. A module whose `node_api_module_get_api_version_v1()` returns more than +8 is rejected before its initializer runs, with the requested and supported +versions in the diagnostic. + +## Environment and handle representation + +`napi_env` points to a host-owned `Env` that is not a GC object. It contains: + +- a unique environment id and the owning `ThreadId`; +- the open handle-scope stack and handle-slot slab; +- references, native callback records, deferred promises, async work, and + threadsafe functions owned by the environment; +- a rooted pending-exception slot and stable `napi_extended_error_info` + storage; +- instance data, environment cleanup hooks, the current module filename, and + a `can_call_into_js` state; +- the environment lifecycle (`loading`, `running`, `closing`, or `closed`). + +The environment is created for the Perry agent before the first addon loads +and destroyed after the event pump has stopped accepting work. Different +Perry agents never share an environment or a heap value. + +### `napi_value` + +A `napi_value` is an opaque pointer to a non-moving host token. The token holds +`(env_id, slot_index, generation)`. The indexed `HandleSlot` holds the Perry +NaN-box bits, its owning scope, its generation, and a live bit. Thus addon code +never observes a raw `ObjectHeader`, `StringHeader`, or other moving heap +address. + +Tokens are stable for the lifetime of the environment and are not reused. +Slots may be reused only after their generation advances. Every entry point +validates all three token fields before reading a value. This makes an illegal +handle use after scope close return `napi_invalid_arg`, rather than aliasing a +new value or dereferencing reclaimed memory. + +Each `ScopeFrame` records the slot indices created in that scope and whether an +escapable scope has already escaped a value. Closing a scope invalidates its +slots. `napi_escape_handle` copies the selected value into a fresh slot in the +parent scope and may succeed once. Module initialization and every native +callback get an implicit scope, so an addon is not required to open a scope +before creating ordinary return values. + +All live scope slots are visited by `scan_node_api_roots_mut`, registered in +`gc_init()` with a descriptive name. The scanner uses mutable NaN-box visits, +so a copied minor or old-generation evacuation rewrites each slot in place. +The pending exception, strong references, callbacks, deferred resolutions, and +queued main-thread completions are visited by the same scanner. The new tables +must be classified in `scripts/gc_runtime_root_holders.json` in the commit that +introduces them. + +The following invariant applies at every host call site: + +> A Perry heap value is written into a live handle slot before any operation +> that can allocate or call JavaScript, and is reread from that slot after the +> operation. + +In particular, the host never caches `HandleSlot.value_bits` in a Rust local +across property access, conversion, callback invocation, or allocation. + +### References, including weak references + +`napi_ref` is a stable host record, also tagged with its environment id and a +generation. A positive reference count stores its value in a strong slot +visited by `scan_node_api_roots_mut`. + +Weak references are in version 1; deferring them would exclude common +node-addon-api and napi-rs patterns. A zero-count reference roots a hidden +Perry `WeakRef` holder, not the target. Perry already skips and rewrites that +holder's weak target slot and clears it during every collection. Consequently: + +1. `napi_create_reference(..., 0, ...)` creates the hidden weak holder while + the input handle is still rooted. +2. `napi_reference_ref` reads the weak target. If it has been collected it + returns `napi_ok` with count zero without resurrecting it; otherwise the + target moves into the strong slot before the weak holder is released. +3. `napi_reference_unref` changing `1 -> 0` creates a weak holder before + clearing the strong slot. +4. `napi_get_reference_value` returns a null C pointer when a weak target has + been collected, matching Node-API; that is distinct from a handle for the + JavaScript value `null`. + +Values that cannot be held weakly (for example, number primitives) remain +strongly retained even at refcount zero, matching Node's reference behavior. +Deleting a reference invalidates the record and releases either root. +Refcounts use checked arithmetic and return `napi_generic_failure` on overflow, +underflow, or an already-deleted record. + +## Native-owned data and finalization + +Wrap data, externals, type tags, and finalizer records live in a per-agent +`NODE_API_OBJECT_META` table keyed by the owning Perry user address. A table +entry contains native pointers and identifiers only; it never keeps its owner +alive. + +The table has both halves required by a moving collector: + +- `scan_node_api_object_meta_keys_mut` visits keys as metadata. It follows + forwarding records and rekeys entries without marking the owner. +- `prune_dead_node_api_object_meta_owners` is registered in + `gc::dead_owner::DEAD_KEY_PRUNES`. Both post-trace and copied-minor fan-out + remove entries whose owners are proven dead. + +The rekey site and death prune receive matching entries in +`scripts/gc_rekeyed_key_tables.json`. Merely registering a strong root scanner +would be incorrect here because it would make every wrapped object immortal. + +Death pruning moves finalizer records into a native pending queue. It does not +call addon code while the collector is marking, rewriting, sweeping, or +holding an arena borrow. The next main-thread safepoint drains the queue inside +an implicit handle scope with `can_call_into_js = true`. That is the stable +version-8 `napi_finalize` contract and is why finalizer invocation depends on +the completed native-to-JavaScript callback boundary. The experimental +`node_api_basic_env` restriction and `node_api_post_finalizer` pairing are +deferred with the rest of that experimental surface. + +`napi_wrap` installs at most one wrap record per object. `napi_unwrap` reads it. +`napi_remove_wrap` atomically removes it, returns the native pointer, and +prevents its finalizer from running. `napi_add_finalizer` may add multiple +independent records. When it returns a `napi_ref`, that record also identifies +the finalizer. Deleting that reference before collection removes the host's +tracking record, so the callback may never run; deleting it from inside the +already-queued callback only releases the reference. + +External buffers and array buffers use the same finalizer queue. Environment +shutdown first prevents new work, drains TSFNs and async completions, runs +cleanup hooks, then enqueues and drains all remaining environment-owned +finalizers exactly once. A finalizer record has an atomic state +`registered -> queued -> running -> complete`, so explicit removal, collection, +and shutdown cannot double-call it. + +No finalizer ordering is promised between different objects. Records attached +to one object are queued in registration order. Environment cleanup hooks use +Node's LIFO order; async cleanup hooks hold shutdown open until their removal +callback is invoked. + +## Status codes and pending exceptions + +Every exported entry point is a plain `extern "C"` boundary and uses one common +prologue/epilogue: + +1. validate the environment, lifecycle, owner thread, input pointers, and + handle generations without panicking; +2. reject JavaScript-capable work when `can_call_into_js` is false; +3. run every operation that may throw through `exception::js_call_catching`; +4. on a Perry throw, root the thrown value in `Env.pending_exception` and + return `napi_pending_exception`; +5. write output parameters only on the statuses for which Node-API defines an + output; +6. update stable per-environment extended-error storage before returning. + +`napi_throw` and its error helpers only set the pending slot. They do not call +`js_throw` while the addon is on the stack. When a native callback returns to +the host trampoline, the trampoline ignores its return value if an exception +is pending, closes the callback scope, and then raises the rooted exception on +the Perry side of the C boundary. This guarantees that neither Perry's system +unwinder nor its `setjmp` transport crosses third-party frames. + +`napi_is_exception_pending` is a pure query. +`napi_get_and_clear_last_exception` creates a handle for the rooted exception +before clearing the environment slot. With no pending exception it writes a +null C pointer. While an exception is pending, ordinary APIs return +`napi_pending_exception`; the exception query/clear and error-information +operations remain available. + +The error-info message is owned by the environment and remains valid until the +next Node-API call on that environment. `engine_error_code` is zero and +`engine_reserved` is null. `napi_fatal_error` writes the length-bounded location +and message directly to stderr and aborts. `napi_fatal_exception` transfers the +error to Perry's uncaught-exception path on the main thread. + +## Functions and native classes + +`napi_create_function` allocates a Perry closure whose captured host record +contains the native callback pointer, addon data pointer, name, and environment +id. A shared rest-argument trampoline constructs a stack-local +`napi_callback_info` containing rooted argument handles, `this`, callback data, +and the current `new_target`. It calls the addon callback inside an implicit +handle scope and converts the returned token back to a Perry value before +closing that scope. + +`napi_call_function` uses Perry's general callable dispatch, not a closure-only +shortcut, so proxies, bound functions, and compiled JavaScript functions keep +their normal semantics. `napi_new_instance` uses the general construction path +and arms Perry's new-target state for the duration of the call. + +`napi_define_class` allocates a synthetic class id and registers its constructor +and prototype in the existing class-id chain. Instance methods/accessors are +defined on the prototype and `napi_static` descriptors on the constructor. +The constructor's callback record owns the class id, and the default instance +is allocated and stamped before the native constructor runs. If the constructor +returns an object, normal JavaScript constructor replacement rules apply. + +The registration also populates the runtime's dynamic-parent/prototype tables, +so a compiled class may extend a native-defined constructor and a native class +may extend another native class. `napi_instanceof` delegates to Perry's normal +`instanceof` operation, including `Symbol.hasInstance`, rather than comparing +only the immediate class id. The Stage 1 gate must include native base-class +subclassing because this crosses the runtime's historically weak dynamic-extends +path. + +Property descriptors preserve `napi_writable`, `napi_enumerable`, and +`napi_configurable`. Getter and setter records use the same native callback +trampoline. A descriptor specifying incompatible `value`/`method`/accessor +fields is rejected before any property is changed. + +## Buffers, views, and external memory + +Perry `Buffer`, typed-array, array-buffer, and data-view byte storage is born +tenured and does not move. A returned native data pointer therefore remains +stable for the lifetime required by Node-API, while the wrapper itself remains +an ordinary rooted handle. + +The host reuses the existing buffer/view registries for type identity, backing +array-buffer identity, offsets, and detach propagation. Creating an external +array buffer or buffer creates a zero-copy wrapper over the supplied bytes and +attaches its callback to `NODE_API_OBJECT_META`. A detached array buffer reports +zero length and null data, and all existing views observe the detach. Buffer +APIs reject detached storage where Node does. + +`napi_adjust_external_memory` updates a signed per-environment counter and the +collector's external-memory pressure accounting. It neither allocates an +equivalent Perry buffer nor silently ignores the request. Underflow clamps at +zero for pressure accounting while the API's returned cumulative value remains +the checked signed total. + +## Async work and threadsafe functions + +`napi_create_async_work` creates an explicit state machine: + +```text +created -> queued -> running -> completing -> complete -> deleted + \-> cancelled -> completing +``` + +Queueing uses `perry_ffi::spawn_blocking`. The execute callback runs on a worker +without access to Perry heap state. Completion is posted through the existing +main-thread event pump and runs inside an implicit scope. Cancellation succeeds +only before execution claims the work; completion still runs with +`napi_cancelled`. Deletion before completion marks the public handle deleted +but retains the internal record until neither worker nor completion owns it. + +A TSFN owns a strong function reference (when a function is supplied), its +context, bounded or unbounded queue, thread count, ref/unref state, and finalizer. +Foreign threads may only call, acquire, or release it. They never touch a +`napi_value` or the Perry collector. `napi_call_threadsafe_function` copies the +opaque data pointer into the queue and notifies the main thread. A blocking call +waits for capacity; a blocking call from the owner thread with a full bounded +queue returns `napi_would_deadlock`. + +The main-thread drain invokes `call_js_cb`, which may then use the environment +and supplied JavaScript callback. Abort release drains remaining items by +calling `call_js_cb` with null environment/function as required by Node-API. +The TSFN finalizer runs after the queue is empty and the thread count reaches +zero. A ref'd TSFN contributes to Perry's event-loop keepalive count; unref +removes that contribution without destroying the function. + +`napi_async_init`, `napi_make_callback`, and `napi_async_destroy` bridge to the +existing `async_hooks` resource/context machinery. Callback scopes are a +validated nesting counter around that context; mismatch returns +`napi_callback_scope_mismatch`. + +## Threading rules + +The following calls are legal from a foreign thread: + +- `napi_call_threadsafe_function` +- `napi_acquire_threadsafe_function` +- `napi_release_threadsafe_function` +- `napi_fatal_error` (it does not return) + +All other entry points require the environment's owner thread, including +`napi_get_threadsafe_function_context`, TSFN ref/unref, reference operations, +and cleanup-hook registration. Entry points without an explicit `napi_env` +recover the owner from their validated opaque record. Misuse returns +`napi_generic_failure`, records a diagnostic when an environment is available, +and never reads or writes Perry heap state. + +The execute half of async work is a foreign thread under this rule. The +complete half, module initialization, cleanup hooks, finalizers, native +callbacks, and TSFN `call_js_cb` all run on the owner thread. + +## Module loading + +The compile graph records every approved `.node` file as a native-addon module +instead of trying to read it as UTF-8. At runtime, one loader operation does the +following: + +1. canonicalize the manifest-selected sidecar path beneath the executable's + sidecar root; +2. return the cached exports object if that canonical file is already loaded; +3. set an environment-local `currently_loading` guard and load with + `RTLD_NOW | RTLD_LOCAL` on Unix or safe `LoadLibraryExW` search flags on + Windows; +4. reject an unresolved `uv_*`, V8, NAN, or non-Node-API Node symbol with the + exact symbol and addon path in the error; +5. if present, call `node_api_module_get_api_version_v1` and reject versions + above 8; +6. prefer `napi_register_module_v1(env, exports)`; otherwise use the descriptor + captured by `napi_module_register` while the library constructor ran; +7. use the initializer's returned object, or the supplied exports object when + the initializer returns null without an exception; +8. cache the rooted exports and library handle together. + +`napi_module_register` outside an active load is an error. Multiple descriptors +from one library are rejected. An initializer exception closes the library, +discards the half-built cache entry, and propagates the rooted exception only +after control has returned from addon code. + +Static `require()` of an approved addon lowers directly to this loader. +`process.dlopen(module, filename[, flags])` calls the same operation and writes +the resulting exports onto the supplied CommonJS module object. Unsupported +flags are rejected rather than ignored. Runtime-computed paths may load only a +file present in the compile-time addon manifest; the allowlist is not a general +`dlopen` capability. + +The environment stores the active canonical module filename during +initialization. It is the future source for the version 9 +`node_api_get_module_file_name` API, even though the version 8 host does not +export that symbol. + +## Linking and exported symbols + +The Node-API implementation lives behind a `node-api-host` runtime feature. +The compiler enables it only when the collected graph contains an approved +addon. A checked-in symbol inventory is the single source for Rust export +retention, platform linker flags, unresolved-symbol validation, and the CI +assertion. + +- macOS removes `-Wl,-no_exported_symbols` only for an addon build and supplies + an exported-symbols list containing the approved `_napi_*` names. +- Linux uses one `--export-dynamic-symbol=` entry per approved symbol, + not broad `--export-dynamic`. +- Windows supplies `/EXPORT:` entries. The addon's standard delay-load + hook resolves its `node.exe` imports against the current executable. + +The loader itself uses the existing `bun_ffi` platform abstraction, extended +to report unresolved imports. Host symbols are retained and exported only when +the compile manifest is non-empty. A hello-world build therefore has exactly +the previous link command and runtime feature set. + +## Opt-in and route precedence + +The only opt-in is an exact package-name list in the project manifest: + +```json +{ + "perry": { + "nativeAddons": ["@swc/core", "oxc-parser"] + } +} +``` + +Transitive packages cannot opt themselves in. The nearest project manifest is +authoritative, duplicate names are normalized, and subpaths inherit their +owning package's decision. Wildcards are not accepted. + +Resolution order is: + +1. a `well_known_bindings.toml` facade; +2. a package's explicit `perry.nativeLibrary`; +3. a project `perry.nativeAddons` entry; +4. ordinary JS/TS compilation; +5. the existing actionable unsupported-addon error. + +Thus listing `better-sqlite3`, `sharp`, or `@parcel/watcher` does not bypass its +Perry facade. Node-API is faithful native execution, so an approved addon is +compatible with `PERRY_REQUIRE_FAITHFUL_BINDINGS=1`; partial hand-written +facade policy remains unchanged. NAN/V8 or direct-libuv imports remain hard +errors even when the package name is allowlisted. + +Desktop/server targets are macOS, Windows, Linux, and the BSDs supported by +Perry's dynamic loader. iOS, tvOS, watchOS, visionOS, Android, HarmonyOS, and +WebAssembly reject `perry.nativeAddons` during target validation. + +## Sidecar distribution + +Addon builds emit a relocatable directory beside the executable: + +```text +app +app.perry-native/ + manifest.json + / + watcher.node + ...package-local shared libraries... +``` + +The compiler copies the selected platform package payload, preserving its +relative layout so `$ORIGIN`/`@loader_path` dependencies keep working. The +manifest records package name and version, target tuple, relative entry path, +SHA-256 for every copied file, and the Node-API policy version. Runtime loading +never consults the build machine's `node_modules` tree. + +This sidecar model has no first-run write, so read-only install directories are +supported. Moving the executable requires moving its `.perry-native` sibling. +Missing or hash-mismatched files fail before `dlopen` with a packaging error. + +For a macOS app bundle the directory is placed under `Contents/Frameworks`. +Every Mach-O sidecar and nested dylib is signed before the outer executable or +bundle is signed; notarization submits the complete bundle. For a loose command +line executable, release tooling signs each sidecar before the executable and +ships them in one archive. Perry does not strip quarantine attributes from +untrusted downloaded binaries at runtime. + +Cross-compilation selects the target's platform package, never the host's. +Perry does not perform an unpinned registry download during compilation. The +target package must be materialized by the package manager/lockfile install; +when it is absent the diagnostic names the exact target tuple and candidate +optional package. This keeps registry credentials and dependency resolution in +the package manager while preventing a host `.node` file from entering a target +artifact. + +## Cache identity + +The compile-time addon manifest is sorted deterministically and contributes +the following to the build and link cache identities: + +- policy schema version and advertised Node-API version; +- target tuple and normalized allowlist; +- selected package name/version and relative entry path; +- SHA-256 and size of every sidecar payload file; +- ordered exported-symbol inventory; +- shipping model (`sidecar-v1`). + +The entry module's object-cache key also includes the canonical logical addon +ids it can load, because those ids appear in generated loader calls. Absolute +build paths do not enter any key or generated object. A sidecar hash change +must miss the top-level build cache even when all TypeScript and object files +are unchanged. + +## Node-API surface inventory + +The inventory is pinned to Node v26.5.1's +[`js_native_api.h`](https://github.com/nodejs/node/blob/v26.5.1/src/js_native_api.h) +and [`node_api.h`](https://github.com/nodejs/node/blob/v26.5.1/src/node_api.h). +`v1` means required before the host is usable. `later` means the declaration is +newer than the advertised version or experimental and is not exported. +`never` means Perry exports the version-8 symbol when necessary for binary +resolution, but it deterministically reports the stated unsupported facility. + +### `js_native_api.h`: core through version 4 + +| Status | Entry points | Notes | +|---|---|---| +| v1 | `napi_get_last_error_info` | Stable per-environment storage | +| v1 | `napi_get_undefined`, `napi_get_null`, `napi_get_global`, `napi_get_boolean` | Singleton values receive ordinary scoped handles | +| v1 | `napi_create_object`, `napi_create_array`, `napi_create_array_with_length` | Perry object/array allocators | +| v1 | `napi_create_double`, `napi_create_int32`, `napi_create_uint32`, `napi_create_int64` | Perry NaN-box conversions | +| v1 | `napi_create_string_latin1`, `napi_create_string_utf8`, `napi_create_string_utf16` | Length-bounded; `NAPI_AUTO_LENGTH` supported | +| v1 | `napi_create_symbol`, `napi_create_function` | Native callbacks use host records | +| v1 | `napi_create_error`, `napi_create_type_error`, `napi_create_range_error` | `code` is installed when supplied | +| v1 | `napi_typeof` | Includes function, external, symbol, and bigint distinctions | +| v1 | `napi_get_value_double`, `napi_get_value_int32`, `napi_get_value_uint32`, `napi_get_value_int64`, `napi_get_value_bool` | Checked type/status behavior | +| v1 | `napi_get_value_string_latin1`, `napi_get_value_string_utf8`, `napi_get_value_string_utf16` | Query-length and NUL-termination semantics included | +| v1 | `napi_coerce_to_bool`, `napi_coerce_to_number`, `napi_coerce_to_object`, `napi_coerce_to_string` | User code is exception-trapped | +| v1 | `napi_get_prototype`, `napi_get_property_names` | General object semantics | +| v1 | `napi_set_property`, `napi_has_property`, `napi_get_property`, `napi_delete_property`, `napi_has_own_property` | String, symbol, and numeric keys | +| v1 | `napi_set_named_property`, `napi_has_named_property`, `napi_get_named_property` | UTF-8 names | +| v1 | `napi_set_element`, `napi_has_element`, `napi_get_element`, `napi_delete_element` | Arrays and exotic indexed objects | +| v1 | `napi_define_properties` | Data, method, and accessor descriptors | +| v1 | `napi_is_array`, `napi_get_array_length`, `napi_strict_equals` | No pointer-identity shortcut | +| v1 | `napi_call_function`, `napi_new_instance`, `napi_instanceof` | General Perry dispatch/construction | +| v1 | `napi_get_cb_info`, `napi_get_new_target`, `napi_define_class` | Callback-info lifetime is the native call | +| v1 | `napi_wrap`, `napi_unwrap`, `napi_remove_wrap`, `napi_create_external`, `napi_get_value_external` | Native object metadata table | +| v1 | `napi_create_reference`, `napi_delete_reference`, `napi_reference_ref`, `napi_reference_unref`, `napi_get_reference_value` | Strong and weak reference design above | +| v1 | `napi_open_handle_scope`, `napi_close_handle_scope`, `napi_open_escapable_handle_scope`, `napi_close_escapable_handle_scope`, `napi_escape_handle` | Strict nesting and generation validation | +| v1 | `napi_throw`, `napi_throw_error`, `napi_throw_type_error`, `napi_throw_range_error`, `napi_is_error` | Pending-slot model | +| v1 | `napi_is_exception_pending`, `napi_get_and_clear_last_exception` | Available while pending | +| v1 | `napi_is_arraybuffer`, `napi_create_arraybuffer`, `napi_create_external_arraybuffer`, `napi_get_arraybuffer_info` | Stable backing pointers | +| v1 | `napi_is_typedarray`, `napi_create_typedarray`, `napi_get_typedarray_info` | All eleven declared typed-array kinds | +| v1 | `napi_create_dataview`, `napi_is_dataview`, `napi_get_dataview_info` | Backing identity and offsets preserved | +| v1 | `napi_get_version` | Returns 8 | +| v1 | `napi_create_promise`, `napi_resolve_deferred`, `napi_reject_deferred`, `napi_is_promise` | Deferred records are environment-owned roots | +| never | `napi_run_script` | Returns `napi_generic_failure`; arbitrary runtime source execution would violate the no-runtime-engine model | +| v1 | `napi_adjust_external_memory` | Collector pressure accounting | + +### `js_native_api.h`: versions 5 through 8 + +| Status | Version | Entry points | Notes | +|---|---:|---|---| +| v1 | 5 | `napi_create_date`, `napi_is_date`, `napi_get_date_value` | Perry `Date` identity/value | +| v1 | 5 | `napi_add_finalizer` | Queued post-GC finalization | +| v1 | 6 | `napi_create_bigint_int64`, `napi_create_bigint_uint64`, `napi_create_bigint_words` | Arbitrary precision | +| v1 | 6 | `napi_get_value_bigint_int64`, `napi_get_value_bigint_uint64`, `napi_get_value_bigint_words` | Exact `lossless` and word-count behavior | +| v1 | 6 | `napi_get_all_property_names` | Collection mode, filter, and key conversion honored | +| v1 | 6 | `napi_set_instance_data`, `napi_get_instance_data` | One record per environment; replacement overwrites without calling the previous finalizer | +| v1 | 7 | `napi_detach_arraybuffer`, `napi_is_detached_arraybuffer` | Existing detach propagation | +| v1 | 8 | `napi_type_tag_object`, `napi_check_object_type_tag` | 128-bit tag in object metadata | +| v1 | 8 | `napi_object_freeze`, `napi_object_seal` | Perry descriptor machinery | + +### `js_native_api.h`: version 9, version 10, and experimental + +| Status | Version | Entry points | Reason | +|---|---:|---|---| +| later | 9 | `node_api_symbol_for`, `node_api_create_syntax_error`, `node_api_throw_syntax_error` | Advertise only with a complete version-9 surface | +| later | 10 | `node_api_create_external_string_latin1`, `node_api_create_external_string_utf16` | Requires external string lifetime/accounting work | +| later | 10 | `node_api_create_property_key_latin1`, `node_api_create_property_key_utf8`, `node_api_create_property_key_utf16` | Version-10 fast-path aliases | +| later | experimental | `node_api_post_finalizer` | Not part of the advertised stable ABI | +| later | experimental | `node_api_create_object_with_properties`, `node_api_set_prototype` | Not part of the advertised stable ABI | +| later | experimental | `node_api_create_sharedarraybuffer`, `node_api_create_external_sharedarraybuffer`, `node_api_is_sharedarraybuffer` | Not part of the advertised stable ABI | + +### `node_api.h` + +| Status | Version | Entry points | Notes | +|---|---:|---|---| +| v1 | base | `napi_module_register`, `napi_fatal_error` | Legacy registration and abort path | +| v1 | base | `napi_async_init`, `napi_async_destroy`, `napi_make_callback` | Existing async-hooks integration | +| v1 | base | `napi_create_buffer`, `napi_create_external_buffer`, `napi_create_buffer_copy`, `napi_is_buffer`, `napi_get_buffer_info` | Perry `Buffer` identity and stable bytes | +| v1 | base | `napi_create_async_work`, `napi_delete_async_work`, `napi_queue_async_work`, `napi_cancel_async_work` | Explicit state machine above | +| v1 | base | `napi_get_node_version` | Returns Perry's semver components with release string `perry`; it does not claim a Node release | +| never | 2 | `napi_get_uv_event_loop` | Returns `napi_generic_failure` and a null loop; Perry has no libuv | +| v1 | 3 | `napi_fatal_exception`, `napi_add_env_cleanup_hook`, `napi_remove_env_cleanup_hook` | Main-thread lifecycle | +| v1 | 3 | `napi_open_callback_scope`, `napi_close_callback_scope` | Async context and strict nesting | +| v1 | 4 | `napi_create_threadsafe_function`, `napi_get_threadsafe_function_context`, `napi_call_threadsafe_function` | Event-pump-backed TSFN | +| v1 | 4 | `napi_acquire_threadsafe_function`, `napi_release_threadsafe_function`, `napi_unref_threadsafe_function`, `napi_ref_threadsafe_function` | Thread count and event-loop keepalive | +| v1 | 8 | `napi_add_async_cleanup_hook`, `napi_remove_async_cleanup_hook` | Shutdown waits for completion | +| later | 9 | `node_api_get_module_file_name` | Environment already records the future value | +| later | 10 | `node_api_create_buffer_from_arraybuffer` | Advertise with version 10 | + +The addon-side initializer exports +`node_api_module_get_api_version_v1` and `napi_register_module_v1`; these are +looked up in the addon and are not host exports. + +## Required gates + +Implementation is not complete until the gates below can independently fail: + +1. **Handle/GC gate:** scopes, strong and weak refs, wrap metadata, callbacks, + and pending exceptions survive forced copied minors and full collections; + stale handles fail generation validation; the root-holder and rekey-table + audit scripts are clean. +2. **Exception gate:** a throwing getter, native callback, finalizer misuse, + and TSFN callback all return through C before Perry raises; an instrumented + addon asserts that no unwind entered its frames. +3. **Loader/export gate:** a real addon records the address of a called + `napi_*` function and the test proves that address belongs to the Perry + executable. The smoke call count must be greater than zero. +4. **Real-addon gate:** one node-addon-api addon and one napi-rs addon cover + properties, classes, async work, TSFN, instance data, wrap/finalizers, and + buffers. Unsupported NAPI version, NAN/V8, and `uv_*` fixtures assert their + exact diagnostics. +5. **Watcher differential gate:** the real `@parcel/watcher` binary and Perry's + facade observe the same fixture tree and emit identical coalesced streams; + both sides assert that their native implementation actually ran. +6. **Distribution gate:** move the executable plus sidecar directory to a + read-only location and load successfully; deletion or mutation of a + sidecar fails the manifest hash check. +7. **Cache gate:** changing only addon bytes or policy misses the build cache; + rebuilding unchanged inputs hits it. +8. **Size gate:** byte-identical hello-world outputs with an empty addon graph; + report the host-only delta for an addon build and enforce the 0.6 MB budget. + +The host is not enabled merely because its unit tests pass. The real-addon and +symbol-provenance gates are the acceptance boundary: a green run in which no +addon initializer or `napi_*` body executed is a failure.