From 2ea11aead7ae8e3996196b0d62463e56da20a2e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 13 Aug 2026 07:20:48 +0200 Subject: [PATCH 1/2] fix(gc): close computed access rooting windows --- .../perry-codegen/src/expr/buffer_access.rs | 50 +- .../src/expr/computed_store_rooting_tests.rs | 171 +- crates/perry-codegen/src/expr/index.rs | 5 +- crates/perry-codegen/src/expr/index_get.rs | 242 +-- crates/perry-codegen/src/expr/index_set.rs | 166 +- .../perry-codegen/src/expr/masked_window.rs | 85 +- .../src/expr/property_get/generic_dispatch.rs | 4 + crates/perry-codegen/src/expr/property_set.rs | 1815 +++++++++-------- .../src/expr/proven_view_access.rs | 4 + .../src/expr/ptr_numarray_access.rs | 92 +- .../test_gap_7640_computed_key_windows.ts | 19 + ...est_gap_gc_class_field_receiver_rooting.ts | 17 +- 12 files changed, 1485 insertions(+), 1185 deletions(-) diff --git a/crates/perry-codegen/src/expr/buffer_access.rs b/crates/perry-codegen/src/expr/buffer_access.rs index e8672090cc..a24064f155 100644 --- a/crates/perry-codegen/src/expr/buffer_access.rs +++ b/crates/perry-codegen/src/expr/buffer_access.rs @@ -460,6 +460,9 @@ pub(crate) fn lower_buffer_store( let Some(proof) = lower_buffer_access_proof(ctx, buffer_expr, index_expr, spec)? else { return Ok(None); }; + // #7640 section E audit: `proof` contains stable slot metadata and the + // lowered native index, not a receiver JSValue or raw backing-store + // pointer. Lower the RHS before `emit_buffer_access_pointer` loads either. let val_i32 = lower_value_i32(ctx, value_expr)?; let emission = emit_buffer_access_pointer(ctx, &proof, spec); let byte_val = ctx.block().trunc(I32, &val_i32, I8); @@ -696,58 +699,67 @@ pub(crate) fn lower_typed_array_store( let Some(proof) = lower_buffer_access_proof(ctx, array_expr, index_expr, spec)? else { return Ok(None); }; + let expected = match proof.view.elem { + BufferElem::I8 | BufferElem::U8 | BufferElem::I16 | BufferElem::U16 | BufferElem::I32 => { + ExpectedNativeRep::I32 + } + BufferElem::U32 => ExpectedNativeRep::U32, + BufferElem::F32 | BufferElem::F64 => ExpectedNativeRep::F64, + BufferElem::U8Clamped => return Ok(None), + }; + // #7640 section E: do not derive `data_ptr` / `elem_ptr` until after the + // RHS has been evaluated. A numeric RHS can still be a call, and a raw + // backing-store pointer cannot be repaired by the JSValue rooting API. + // The view proof guarantees this binding/data slot is stable, so loading it + // here preserves the already-selected receiver without holding a raw pointer + // across the call. + let result = lower_expr_native(ctx, value_expr, expected)?; let emission = emit_buffer_access_pointer(ctx, &proof, spec); - let (stored, result) = match proof.view.elem { + let stored = match proof.view.elem { BufferElem::I8 | BufferElem::U8 => { - let value = lower_expr_native(ctx, value_expr, ExpectedNativeRep::I32)?; - let byte = ctx.block().trunc(I32, &value.value, I8); + let byte = ctx.block().trunc(I32, &result.value, I8); ctx.block().emit_raw(format!( "store i8 {}, ptr {}{}", byte, emission.elem_ptr, emission.alias_metadata )); - (LoweredValue::u8(byte), value) + LoweredValue::u8(byte) } BufferElem::I16 | BufferElem::U16 => { - let value = lower_expr_native(ctx, value_expr, ExpectedNativeRep::I32)?; - let half = ctx.block().trunc(I32, &value.value, I16); + let half = ctx.block().trunc(I32, &result.value, I16); ctx.block().emit_raw(format!( "store i16 {}, ptr {}{}", half, emission.elem_ptr, emission.alias_metadata )); - (LoweredValue::i32(value.value.clone()), value) + LoweredValue::i32(result.value.clone()) } BufferElem::I32 => { - let value = lower_expr_native(ctx, value_expr, ExpectedNativeRep::I32)?; ctx.block().emit_raw(format!( "store i32 {}, ptr {}{}", - value.value, emission.elem_ptr, emission.alias_metadata + result.value, emission.elem_ptr, emission.alias_metadata )); - (LoweredValue::i32(value.value.clone()), value) + LoweredValue::i32(result.value.clone()) } BufferElem::U32 => { - let value = lower_expr_native(ctx, value_expr, ExpectedNativeRep::U32)?; ctx.block().emit_raw(format!( "store i32 {}, ptr {}{}", - value.value, emission.elem_ptr, emission.alias_metadata + result.value, emission.elem_ptr, emission.alias_metadata )); - (LoweredValue::u32(value.value.clone()), value) + LoweredValue::u32(result.value.clone()) } BufferElem::F32 => { - let value = lower_expr_native(ctx, value_expr, ExpectedNativeRep::F64)?; - let narrow = ctx.block().fptrunc(DOUBLE, &value.value, F32); + let narrow = ctx.block().fptrunc(DOUBLE, &result.value, F32); ctx.block().emit_raw(format!( "store float {}, ptr {}{}", narrow, emission.elem_ptr, emission.alias_metadata )); - (LoweredValue::f32(narrow), value) + LoweredValue::f32(narrow) } BufferElem::F64 => { - let value = lower_expr_native(ctx, value_expr, ExpectedNativeRep::F64)?; ctx.block().emit_raw(format!( "store double {}, ptr {}{}", - value.value, emission.elem_ptr, emission.alias_metadata + result.value, emission.elem_ptr, emission.alias_metadata )); - (LoweredValue::f64(value.value.clone()), value) + LoweredValue::f64(result.value.clone()) } BufferElem::U8Clamped => return Ok(None), }; diff --git a/crates/perry-codegen/src/expr/computed_store_rooting_tests.rs b/crates/perry-codegen/src/expr/computed_store_rooting_tests.rs index b8871d4817..67ec7294aa 100644 --- a/crates/perry-codegen/src/expr/computed_store_rooting_tests.rs +++ b/crates/perry-codegen/src/expr/computed_store_rooting_tests.rs @@ -1,5 +1,6 @@ -//! Rooting coverage for the three computed-store arms slice 4 repaired -//! (#7637, #7638, #7639), built from HIR rather than from TypeScript. +//! Rooting coverage for the computed-store arms repaired in slice 4 (#7637, +//! #7638, #7639) and the remaining #7640 read/write windows, built from HIR +//! rather than from TypeScript. //! //! # Why these are unit tests and not gap tests //! @@ -23,9 +24,10 @@ //! //! # What each test asserts, and why it cannot pass vacuously //! -//! Each builds the SAME store twice, changing one thing: the right-hand side is -//! either an allocating `Expr::Object` or an inert `Expr::Number`. Then it -//! compares the shadow-frame width the function reserves. +//! Each differential test builds the SAME access twice, changing one thing: +//! a later operand is either allocating or inert. Then it compares the +//! shadow-frame width the function reserves. The realloc-barrier regression at +//! the end instead names the exact SSA head consumed by its barrier. //! //! - `Expr::Number` ⇒ `expr_may_trigger_gc` is false ⇒ `operand_protection` //! returns `Reuse` ⇒ the combinator emits nothing at all, which is the @@ -39,16 +41,20 @@ //! width measured over a store that never got emitted would be hazard 4. use perry_hir::types::Type; -use perry_hir::{Expr, Function, Module as HirModule, Stmt}; +use perry_hir::{Expr, Function, Module as HirModule, Param, Stmt}; /// Compile a one-function module and return its LLVM IR. fn compile_body(name: &str, body: Vec) -> String { + compile_body_with_params(name, Vec::new(), body) +} + +fn compile_body_with_params(name: &str, params: Vec, body: Vec) -> String { let mut hir = HirModule::new(name); hir.functions.push(Function { id: 0, name: "build".to_string(), type_params: Vec::new(), - params: Vec::new(), + params, return_type: Type::Any, body, is_async: false, @@ -68,6 +74,18 @@ fn compile_body(name: &str, body: Vec) -> String { String::from_utf8(bytes).expect("LLVM IR is UTF-8") } +fn param(id: u32, name: &str, ty: Type) -> Param { + Param { + id, + name: name.to_string(), + ty, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + } +} + /// How many root slots the module's code reserves. /// /// Counted under BOTH root lowerings on purpose, because which one runs is an @@ -220,3 +238,142 @@ fn polymorphic_index_store_roots_both_operands_across_an_allocating_rhs() { }, ); } + +/// #7640 B tail — declared typed-array dispatch still accepts an arbitrary +/// property key. Evaluating that key may collect before the runtime helper +/// consumes the receiver. +#[test] +fn typed_array_runtime_key_read_roots_receiver_only_when_key_collects() { + let compile = |label: &str, key: Expr| { + compile_body_with_params( + label, + vec![param(1, "ta", Type::Named("Int32Array".to_string()))], + vec![Stmt::Return(Some(Expr::IndexGet { + object: Box::new(Expr::LocalGet(1)), + index: Box::new(key), + }))], + ) + }; + let collecting = compile("ta_runtime_key_collecting", allocating_value()); + let inert = compile("ta_runtime_key_inert", Expr::Undefined); + let callee = "@js_typed_array_index_get_dynamic("; + assert!( + collecting.contains(callee) && inert.contains(callee), + "both fixtures must reach the typed-array runtime-key arm:\n{collecting}\n{inert}" + ); + assert!( + root_slots(&collecting) > root_slots(&inert), + "an allocating runtime key must protect the typed-array receiver" + ); +} + +/// #7640 A tail — the runtime-key store consumes receiver, key, and value only +/// after all three JavaScript operands have been evaluated. +#[test] +fn typed_array_runtime_key_store_roots_operands_only_when_rhs_collects() { + let compile = |label: &str, value: Expr| { + compile_body_with_params( + label, + vec![ + param(1, "ta", Type::Named("Int32Array".to_string())), + param(2, "key", Type::Any), + ], + vec![Stmt::Expr(Expr::IndexSet { + object: Box::new(Expr::LocalGet(1)), + index: Box::new(Expr::LocalGet(2)), + value: Box::new(value), + })], + ) + }; + let collecting = compile("ta_runtime_store_collecting", allocating_value()); + let inert = compile("ta_runtime_store_inert", inert_value()); + let callee = "@js_typed_array_index_set_dynamic("; + assert!( + collecting.contains(callee) && inert.contains(callee), + "both fixtures must reach the typed-array runtime-key store arm:\n{collecting}\n{inert}" + ); + assert!( + root_slots(&collecting) > root_slots(&inert), + "an allocating RHS must protect the typed-array receiver and key" + ); +} + +/// #7640 A tail — the #5525 inline dynamic typed-array route accepts erased +/// receiver/key types, then lowers a custom-representation RHS before either +/// is consumed by the guard diamond. +#[test] +fn erased_receiver_inline_store_roots_receiver_and_key_across_rhs() { + let compile = |label: &str, value: Expr| { + compile_body_with_params( + label, + vec![param(1, "receiver", Type::Any), param(2, "key", Type::Any)], + vec![Stmt::Expr(Expr::IndexSet { + object: Box::new(Expr::LocalGet(1)), + index: Box::new(Expr::LocalGet(2)), + value: Box::new(value), + })], + ) + }; + let collecting = compile("erased_store_collecting", allocating_value()); + let inert = compile("erased_store_inert", inert_value()); + let callee = "@js_dyn_index_set("; + assert!( + collecting.contains(callee) && inert.contains(callee), + "both fixtures must reach the #5525 inline dynamic-store arm:\n{collecting}\n{inert}" + ); + let extra_slots = root_slots(&collecting).saturating_sub(root_slots(&inert)); + assert!( + extra_slots >= 2, + "an allocating RHS must protect both erased operands; expected at least two extra slots, got {extra_slots}" + ); +} + +/// #7640 E — the array-grow helper may return a replacement allocation. The +/// write barrier on that path must therefore shade through the returned head, +/// not the raw receiver handle computed before the call. +#[test] +fn growing_array_store_uses_the_reallocated_head_for_its_barrier() { + let ir = compile_body( + "array_grow_barrier_head", + vec![ + Stmt::Let { + id: 1, + name: "arr".to_string(), + ty: Type::Array(Box::new(Type::Any)), + mutable: false, + init: Some(Expr::Array(vec![Expr::Undefined])), + }, + Stmt::Expr(Expr::IndexSet { + object: Box::new(Expr::LocalGet(1)), + index: Box::new(Expr::Integer(8)), + value: Box::new(allocating_value()), + }), + ], + ); + let realloc_call = ir + .lines() + .find(|line| line.contains(" = call i64 @js_array_set_f64_extend")) + .unwrap_or_else(|| panic!("fixture never emitted the realloc path:\n{ir}")); + let new_head = realloc_call + .split_once(" = ") + .map(|(result, _)| result.trim()) + .expect("realloc call has an SSA result"); + let realloc_label = ir + .lines() + .position(|line| line.starts_with("idxset.realloc.")) + .unwrap_or_else(|| panic!("fixture never emitted an idxset.realloc block:\n{ir}")); + let realloc_body = ir + .lines() + .skip(realloc_label + 1) + .take_while(|line| line.starts_with(char::is_whitespace) || line.is_empty()) + .collect::>() + .join("\n"); + let barrier = realloc_body + .lines() + .find(|line| line.contains("@js_write_barrier_slot(")) + .unwrap_or_else(|| panic!("realloc path lost its write barrier:\n{realloc_body}")); + assert!( + barrier.contains(&format!("i64 {new_head}")), + "the realloc-path barrier must use {new_head}, returned by the grow helper; got `{barrier}`" + ); +} diff --git a/crates/perry-codegen/src/expr/index.rs b/crates/perry-codegen/src/expr/index.rs index e9d2e9c0c9..4bfa6cdfee 100644 --- a/crates/perry-codegen/src/expr/index.rs +++ b/crates/perry-codegen/src/expr/index.rs @@ -666,7 +666,10 @@ pub(crate) fn lower_index_set_fast( let new_box = nanbox_pointer_inline(blk, &new_handle); blk.store(DOUBLE, &new_box, &slot); let val_bits = blk.bitcast_double_to_i64(val_double); - emit_write_barrier_slot_on_block(blk, &arr_handle, "0", &val_bits); + // #7640 section E: the grow helper can return a replacement allocation. + // The pre-call raw handle then names the forwarding source, not the array + // that received the value. Use the returned live head for the barrier. + emit_write_barrier_slot_on_block(blk, &new_handle, "0", &val_bits); blk.br(&merge_label); } diff --git a/crates/perry-codegen/src/expr/index_get.rs b/crates/perry-codegen/src/expr/index_get.rs index 9c31132123..36e93ca1b2 100644 --- a/crates/perry-codegen/src/expr/index_get.rs +++ b/crates/perry-codegen/src/expr/index_get.rs @@ -473,11 +473,9 @@ pub(crate) fn lower_numeric_index_get_for_number_context( if let Some(fact) = super::masked_window::masked_window_fact_for_index(ctx, *arr_id, index.as_ref()) { - let arr_box = lower_expr(ctx, object)?; - let idx_i32 = lower_expr_as_i32(ctx, index)?; return Ok(Some(super::masked_window::lower_masked_window_index_get( - ctx, *arr_id, &arr_box, &idx_i32, &fact, - ))); + ctx, *arr_id, object, index, &fact, + )?)); } } if !is_array_expr(ctx, object) || !expr_has_numeric_pointer_free_array_layout(ctx, object) { @@ -550,25 +548,29 @@ pub(crate) fn lower_numeric_index_get_for_number_context( } let repair_slot = receiver_repair_slot(ctx, object); - let arr_box = lower_expr(ctx, object)?; if !numeric_index_has_integer_array_index_proof(ctx, index) { - let idx_double = lower_expr(ctx, index)?; - return Ok(Some(lower_array_index_get_via_runtime_key( - ctx, - &arr_box, - &idx_double, - true, - ))); + return rooting::with_operands_rooted(ctx, &[object, index], |ctx, vals| { + Ok(Some(lower_array_index_get_via_runtime_key( + ctx, &vals[0], &vals[1], true, + ))) + }); } - let idx_i32 = lower_expr_as_i32(ctx, index)?; - lower_guarded_array_index_get( + rooting::with_operands_rooted_across( ctx, - &arr_box, - &idx_i32, - "arr", - true, - true, - repair_slot.as_deref(), + &[object], + &[index], + |ctx| lower_expr_as_i32(ctx, index), + |ctx, vals, idx_i32| { + lower_guarded_array_index_get( + ctx, + &vals[0], + &idx_i32, + "arr", + true, + true, + repair_slot.as_deref(), + ) + }, ) .map(Some) } @@ -613,11 +615,9 @@ pub(crate) fn lower_unknown_local_index_get_for_number_context( // whole index window, so the read needs no per-access cache probe at all. if let Some(fact) = super::masked_window::masked_window_fact_for_index(ctx, *id, index.as_ref()) { - let arr_box = lower_expr(ctx, object)?; - let idx_i32 = lower_expr_as_i32(ctx, index)?; return Ok(Some(super::masked_window::lower_masked_window_index_get( - ctx, *id, &arr_box, &idx_i32, &fact, - ))); + ctx, *id, object, index, &fact, + )?)); } let recv_unknown = matches!( crate::type_analysis::static_type_of(ctx, object), @@ -922,15 +922,59 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } } } - let arr_box = lower_expr(ctx, object)?; - let key_box = lower_expr(ctx, index)?; + return rooting::with_operands_rooted(ctx, &[object, index], |ctx, vals| { + let blk = ctx.block(); + let arr_bits = blk.bitcast_double_to_i64(&vals[0]); + let arr_i64 = blk.and(I64, &arr_bits, POINTER_MASK_I64); + let result = blk.call( + DOUBLE, + "js_typed_array_index_get_dynamic", + &[(I64, &arr_i64), (DOUBLE, &vals[1])], + ); + let slow = LoweredValue::js_value(result.clone()); + ctx.record_lowered_value_with_access_mode( + "TypedArrayGet", + None, + "TypedArrayGet.slow_path", + &slow, + Some(BoundsState::Unknown), + None, + Some(BufferAccessMode::DynamicFallback), + Some(buffer_access_materialization_reason(ctx, object)), + false, + false, + vec!["typed_array_fallback=untracked_or_unproven".to_string()], + ); + attach_buffer_view_pointer_state_for_expr(ctx, object); + Ok(result) + }); + } + + // Numeric-context read of a typed-array PARAM (e.g. bcryptjs + // `_encipher`'s `n = S[l >>> 24]; n += S[...]`): an inline checked + // f64 load that is bit-exact with `js_typed_array_get` (numeric + // element in-bounds, `TAG_UNDEFINED` OOB), replacing the per-read + // runtime call. Gated on a proven integer index; guard misses + // (view/detached/wrong-kind) defer to the memory-safe helper. + if let Some(value) = + super::ta_param_f64_read::try_lower_ta_param_f64_read(ctx, object, index)? + { + return Ok(value); + } + + // Width-aware typed-array native lowering is only sound for + // tracked fresh views with proven/guarded element bounds. All + // aliases, reassigned locals, and unknown bounds stay on the + // runtime helper, with artifact evidence for the fallback. + return rooting::with_operands_rooted(ctx, &[object, index], |ctx, vals| { let blk = ctx.block(); - let arr_bits = blk.bitcast_double_to_i64(&arr_box); + let arr_bits = blk.bitcast_double_to_i64(&vals[0]); let arr_i64 = blk.and(I64, &arr_bits, POINTER_MASK_I64); + let idx_i32 = blk.fptosi(DOUBLE, &vals[1], I32); let result = blk.call( DOUBLE, - "js_typed_array_index_get_dynamic", - &[(I64, &arr_i64), (DOUBLE, &key_box)], + "js_typed_array_get", + &[(I64, &arr_i64), (I32, &idx_i32)], ); let slow = LoweredValue::js_value(result.clone()); ctx.record_lowered_value_with_access_mode( @@ -947,52 +991,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { vec!["typed_array_fallback=untracked_or_unproven".to_string()], ); attach_buffer_view_pointer_state_for_expr(ctx, object); - return Ok(result); - } - - // Numeric-context read of a typed-array PARAM (e.g. bcryptjs - // `_encipher`'s `n = S[l >>> 24]; n += S[...]`): an inline checked - // f64 load that is bit-exact with `js_typed_array_get` (numeric - // element in-bounds, `TAG_UNDEFINED` OOB), replacing the per-read - // runtime call. Gated on a proven integer index; guard misses - // (view/detached/wrong-kind) defer to the memory-safe helper. - if let Some(value) = - super::ta_param_f64_read::try_lower_ta_param_f64_read(ctx, object, index)? - { - return Ok(value); - } - - // Width-aware typed-array native lowering is only sound for - // tracked fresh views with proven/guarded element bounds. All - // aliases, reassigned locals, and unknown bounds stay on the - // runtime helper, with artifact evidence for the fallback. - let arr_box = lower_expr(ctx, object)?; - let idx_double = lower_expr(ctx, index)?; - let blk = ctx.block(); - let arr_bits = blk.bitcast_double_to_i64(&arr_box); - let arr_i64 = blk.and(I64, &arr_bits, POINTER_MASK_I64); - let idx_i32 = blk.fptosi(DOUBLE, &idx_double, I32); - let result = blk.call( - DOUBLE, - "js_typed_array_get", - &[(I64, &arr_i64), (I32, &idx_i32)], - ); - let slow = LoweredValue::js_value(result.clone()); - ctx.record_lowered_value_with_access_mode( - "TypedArrayGet", - None, - "TypedArrayGet.slow_path", - &slow, - Some(BoundsState::Unknown), - None, - Some(BufferAccessMode::DynamicFallback), - Some(buffer_access_materialization_reason(ctx, object)), - false, - false, - vec!["typed_array_fallback=untracked_or_unproven".to_string()], - ); - attach_buffer_view_pointer_state_for_expr(ctx, object); - return Ok(result); + Ok(result) + }); } if is_uint8array_receiver(ctx, object) && is_numeric_expr(ctx, index) { if let Some(value) = @@ -1002,16 +1002,16 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { return Ok(materialize_js_value(ctx, value, reason)); } if typed_array_index_needs_runtime_key(ctx, object.as_ref(), index.as_ref()) { - let arr_box = lower_expr(ctx, object)?; - let key_box = lower_expr(ctx, index)?; - let blk = ctx.block(); - let arr_bits = blk.bitcast_double_to_i64(&arr_box); - let arr_i64 = blk.and(I64, &arr_bits, POINTER_MASK_I64); - return Ok(blk.call( - DOUBLE, - "js_typed_array_index_get_dynamic", - &[(I64, &arr_i64), (DOUBLE, &key_box)], - )); + return rooting::with_operands_rooted(ctx, &[object, index], |ctx, vals| { + let blk = ctx.block(); + let arr_bits = blk.bitcast_double_to_i64(&vals[0]); + let arr_i64 = blk.and(I64, &arr_bits, POINTER_MASK_I64); + Ok(blk.call( + DOUBLE, + "js_typed_array_index_get_dynamic", + &[(I64, &arr_i64), (DOUBLE, &vals[1])], + )) + }); } // #6088: the index is a proven non-negative i32 key, but the // inline load above bailed, so its value is NOT proven in @@ -1022,15 +1022,21 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // unproven-bounds slow path through the JS-value getter (robust // for both a real Uint8Array and a Buffer-backed receiver) — // in-range reads still return the byte as a number. - let arr_box = lower_expr(ctx, object)?; - let idx_i32 = lower_expr_as_i32(ctx, index)?; - let blk = ctx.block(); - let handle = unbox_to_i64(blk, &arr_box); - return Ok(blk.call( - DOUBLE, - "js_uint8array_index_get_value", - &[(I64, &handle), (I32, &idx_i32)], - )); + return rooting::with_operands_rooted_across( + ctx, + &[object], + &[index], + |ctx| lower_expr_as_i32(ctx, index), + |ctx, vals, idx_i32| { + let blk = ctx.block(); + let handle = unbox_to_i64(blk, &vals[0]); + Ok(blk.call( + DOUBLE, + "js_uint8array_index_get_value", + &[(I64, &handle), (I32, &idx_i32)], + )) + }, + ); } // Scalar-replaced array literal: `arr[k]` where arr was bound to // `[...]` and never escaped, and k is a compile-time index in @@ -1154,11 +1160,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { if let Some(fact) = super::masked_window::masked_window_fact_for_index(ctx, *arr_id, index.as_ref()) { - let arr_box = lower_expr(ctx, object)?; - let idx_i32 = lower_expr_as_i32(ctx, index)?; return Ok(super::masked_window::lower_masked_window_index_get( - ctx, *arr_id, &arr_box, &idx_i32, &fact, - )); + ctx, *arr_id, object, index, &fact, + )?); } } // Issue #514: when the receiver's static type is genuinely @@ -1322,11 +1326,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { *arr_id, index.as_ref(), ) { - let arr_box = lower_expr(ctx, object)?; - let idx_i32 = lower_expr_as_i32(ctx, index)?; return Ok(super::masked_window::lower_masked_window_index_get( - ctx, *arr_id, &arr_box, &idx_i32, &fact, - )); + ctx, *arr_id, object, index, &fact, + )?); } } if let (Expr::LocalGet(arr_id), Expr::LocalGet(idx_id)) = @@ -1356,17 +1358,13 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } let repair_slot = receiver_repair_slot(ctx, object); - let arr_box = lower_expr(ctx, object)?; if !numeric_index_has_integer_array_index_proof(ctx, index) { - let idx_double = lower_expr(ctx, index)?; - return Ok(lower_array_index_get_via_runtime_key( - ctx, - &arr_box, - &idx_double, - false, - )); + return rooting::with_operands_rooted(ctx, &[object, index], |ctx, vals| { + Ok(lower_array_index_get_via_runtime_key( + ctx, &vals[0], &vals[1], false, + )) + }); } - let idx_i32 = lower_expr_as_i32(ctx, index)?; // #6132: a member receiver of unknown type (e.g. `n.buf[i]` where // `n.buf` is a Uint32Array) must NOT go through the legacy inline // reader — that path reads the value as a plain `ArrayHeader` @@ -1376,14 +1374,22 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // the typed-feedback-guarded path: its runtime guard rejects // non-plain arrays and takes the boxed fallback (which dispatches // typed arrays correctly), while plain arrays keep the fast path. - return lower_guarded_array_index_get( + return rooting::with_operands_rooted_across( ctx, - &arr_box, - &idx_i32, - "arr", - require_numeric_layout, - false, - repair_slot.as_deref(), + &[object], + &[index], + |ctx| lower_expr_as_i32(ctx, index), + |ctx, vals, idx_i32| { + lower_guarded_array_index_get( + ctx, + &vals[0], + &idx_i32, + "arr", + require_numeric_layout, + false, + repair_slot.as_deref(), + ) + }, ); } // Generic dynamic object access: stringify the index (no-op diff --git a/crates/perry-codegen/src/expr/index_set.rs b/crates/perry-codegen/src/expr/index_set.rs index edcbbc021a..54e1fea4cd 100644 --- a/crates/perry-codegen/src/expr/index_set.rs +++ b/crates/perry-codegen/src/expr/index_set.rs @@ -852,22 +852,50 @@ pub(crate) fn lower( )); } if typed_array_index_needs_runtime_key(ctx, object.as_ref(), index.as_ref()) { - let arr_box = lower_expr(ctx, object)?; - let idx_double = lower_expr(ctx, index)?; - let val_double = lower_expr(ctx, value)?; + return rooting::with_operands_rooted( + ctx, + &[object, index, value], + |ctx, vals| { + let blk = ctx.block(); + let arr_bits = blk.bitcast_double_to_i64(&vals[0]); + let arr_i64 = blk.and(I64, &arr_bits, POINTER_MASK_I64); + let result = blk.call( + DOUBLE, + "js_typed_array_index_set_dynamic", + &[(I64, &arr_i64), (DOUBLE, &vals[1]), (DOUBLE, &vals[2])], + ); + let slow = LoweredValue::js_value(result.clone()); + ctx.record_lowered_value_with_access_mode( + "TypedArraySet", + None, + "TypedArraySet.slow_path", + &slow, + Some(BoundsState::Unknown), + None, + Some(BufferAccessMode::DynamicFallback), + Some(buffer_access_materialization_reason(ctx, object)), + false, + false, + vec!["typed_array_fallback=untracked_or_unproven".to_string()], + ); + attach_buffer_view_pointer_state_for_expr(ctx, object); + Ok(result) + }, + ); + } + + // Stores fall back for untracked views, unknown bounds, unsafe + // conversions, and Uint8ClampedArray's ToUint8Clamp semantics. + return rooting::with_operands_rooted(ctx, &[object, index, value], |ctx, vals| { let blk = ctx.block(); - let arr_bits = blk.bitcast_double_to_i64(&arr_box); + let arr_bits = blk.bitcast_double_to_i64(&vals[0]); let arr_i64 = blk.and(I64, &arr_bits, POINTER_MASK_I64); - let result = blk.call( - DOUBLE, - "js_typed_array_index_set_dynamic", - &[ - (I64, &arr_i64), - (DOUBLE, &idx_double), - (DOUBLE, &val_double), - ], + let idx_i32 = blk.fptosi(DOUBLE, &vals[1], I32); + blk.call_void( + "js_typed_array_set", + &[(I64, &arr_i64), (I32, &idx_i32), (DOUBLE, &vals[2])], ); - let slow = LoweredValue::js_value(result.clone()); + let slow = LoweredValue::js_value(vals[2].clone()); ctx.record_lowered_value_with_access_mode( "TypedArraySet", None, @@ -882,38 +910,8 @@ pub(crate) fn lower( vec!["typed_array_fallback=untracked_or_unproven".to_string()], ); attach_buffer_view_pointer_state_for_expr(ctx, object); - return Ok(result); - } - - // Stores fall back for untracked views, unknown bounds, unsafe - // conversions, and Uint8ClampedArray's ToUint8Clamp semantics. - let arr_box = lower_expr(ctx, object)?; - let idx_double = lower_expr(ctx, index)?; - let val_double = lower_expr(ctx, value)?; - let blk = ctx.block(); - let arr_bits = blk.bitcast_double_to_i64(&arr_box); - let arr_i64 = blk.and(I64, &arr_bits, POINTER_MASK_I64); - let idx_i32 = blk.fptosi(DOUBLE, &idx_double, I32); - blk.call_void( - "js_typed_array_set", - &[(I64, &arr_i64), (I32, &idx_i32), (DOUBLE, &val_double)], - ); - let slow = LoweredValue::js_value(val_double.clone()); - ctx.record_lowered_value_with_access_mode( - "TypedArraySet", - None, - "TypedArraySet.slow_path", - &slow, - Some(BoundsState::Unknown), - None, - Some(BufferAccessMode::DynamicFallback), - Some(buffer_access_materialization_reason(ctx, object)), - false, - false, - vec!["typed_array_fallback=untracked_or_unproven".to_string()], - ); - attach_buffer_view_pointer_state_for_expr(ctx, object); - return Ok(val_double); + Ok(vals[2].clone()) + }); } if is_uint8array_receiver(ctx, object) && is_numeric_expr(ctx, index) { if let Some(store) = lower_buffer_store( @@ -933,21 +931,20 @@ pub(crate) fn lower( )); } if typed_array_index_needs_runtime_key(ctx, object.as_ref(), index.as_ref()) { - let arr_box = lower_expr(ctx, object)?; - let idx_double = lower_expr(ctx, index)?; - let val_double = lower_expr(ctx, value)?; - let blk = ctx.block(); - let arr_bits = blk.bitcast_double_to_i64(&arr_box); - let arr_i64 = blk.and(I64, &arr_bits, POINTER_MASK_I64); - return Ok(blk.call( - DOUBLE, - "js_typed_array_index_set_dynamic", - &[ - (I64, &arr_i64), - (DOUBLE, &idx_double), - (DOUBLE, &val_double), - ], - )); + return rooting::with_operands_rooted( + ctx, + &[object, index, value], + |ctx, vals| { + let blk = ctx.block(); + let arr_bits = blk.bitcast_double_to_i64(&vals[0]); + let arr_i64 = blk.and(I64, &arr_bits, POINTER_MASK_I64); + Ok(blk.call( + DOUBLE, + "js_typed_array_index_set_dynamic", + &[(I64, &arr_i64), (DOUBLE, &vals[1]), (DOUBLE, &vals[2])], + )) + }, + ); } } // #5525: when the receiver's static type is genuinely unknown @@ -986,29 +983,34 @@ pub(crate) fn lower( Expr::String(_) | Expr::WtfString(_) | Expr::SymbolFor(_) ) || is_string_expr(ctx, index); if recv_unknown && !index_is_static_string_or_symbol { - let obj_box = lower_expr(ctx, object)?; - let idx_d = lower_expr(ctx, index)?; - // Keep the RHS on the js_value_bits evidence contract even on - // the #5525 inline typed-array route — the slow edge hands the - // boxed value to `js_dyn_index_set` unchanged, and the fast - // edge's per-kind conversion matches the runtime store exactly. - let (val_double, _val_bits) = lower_value_for_dynamic_index_set( - ctx, - value, - "index_set.dynamic_value_bits", - "polymorphic_index_set_helper_edge", - )?; - // #5525 follow-up: guarded inline typed-array element STORE at the - // access site, mirroring the inline read in index_get.rs. Removes - // the per-element out-of-line `js_dyn_index_set` call + - // `lookup_typed_array_kind` for bcrypt's `P[i]=`/`S[i]=` writes, - // falling back to `js_dyn_index_set` on any guard miss. - return Ok(lower_inline_dyn_typed_array_set( + return rooting::with_operands_rooted_across( ctx, - &obj_box, - &idx_d, - &val_double, - )); + &[object, index], + &[value], + |ctx| { + // Keep the RHS on the js_value_bits evidence contract even + // on the #5525 inline typed-array route — the slow edge + // hands the boxed value to `js_dyn_index_set` unchanged. + lower_value_for_dynamic_index_set( + ctx, + value, + "index_set.dynamic_value_bits", + "polymorphic_index_set_helper_edge", + ) + }, + |ctx, vals, (val_double, _val_bits)| { + // #5525 follow-up: guarded inline typed-array element STORE + // at the access site, falling back to `js_dyn_index_set` on + // any guard miss. #7640: both the receiver and key are + // re-read after the allocating RHS. + Ok(lower_inline_dyn_typed_array_set( + ctx, + &vals[0], + &vals[1], + &val_double, + )) + }, + ); } // Issue #637 / hono r2 followup: `arr[stringKey] = val` where // the index is statically string-typed (e.g. `for (const i in diff --git a/crates/perry-codegen/src/expr/masked_window.rs b/crates/perry-codegen/src/expr/masked_window.rs index 69064fc29d..6aa1a6261b 100644 --- a/crates/perry-codegen/src/expr/masked_window.rs +++ b/crates/perry-codegen/src/expr/masked_window.rs @@ -16,6 +16,7 @@ use crate::nanbox::POINTER_MASK_I64; use crate::native_value::{ BoundsState, BufferAccessMode, LoweredValue, NativeFactUse, NativeRep, SemanticKind, }; +use crate::rooting; use crate::types::{DOUBLE, I32, I64}; use super::{ @@ -153,42 +154,56 @@ fn window_layout_facts(fact: &MaskedWindowArrayFact, arr_id: u32) -> (Vec, arr_id: u32, - arr_box: &str, - idx_i32: &str, + object: &Expr, + index: &Expr, fact: &MaskedWindowArrayFact, -) -> String { - let value = emit_window_load_f64(ctx, arr_box, idx_i32, fact); - let lowered = LoweredValue { - semantic: SemanticKind::JsNumber, - rep: NativeRep::F64, - llvm_ty: DOUBLE, - value: value.clone(), - }; - let (layout_facts, layout_note) = window_layout_facts(fact, arr_id); - ctx.record_lowered_value_with_access_mode_and_facts( - "NumericArrayIndexGet", - Some(arr_id), - "packed_f64_masked_window_load", - &lowered, - Some(BoundsState::Guarded { - guard_id: fact.guard_id.clone(), - }), - None, - Some(BufferAccessMode::CheckedNative), - None, - None, - None, - layout_facts, - Vec::new(), - false, - false, - vec![ - "index_range=static_window_guarded".to_string(), - "length_range=guarded_i32".to_string(), - layout_note, - ], - ); - value +) -> Result { + // #7640 section E: `static_index_window` admits `call() & MASK` and + // `indexGet() >>> SHIFT`, so the native-i32 index lowering can run arbitrary + // user code. Preserve the already-evaluated receiver across that custom + // lowering, then derive the raw handle only from the post-window re-read. + // Literal/local hot indexes still make `operand_protection` answer `Reuse`, + // so this adds no root traffic to the ordinary masked-loop path. + rooting::with_operands_rooted_across( + ctx, + &[object], + &[index], + |ctx| lower_expr_as_i32(ctx, index), + |ctx, vals, idx_i32| { + let value = emit_window_load_f64(ctx, &vals[0], &idx_i32, fact); + let lowered = LoweredValue { + semantic: SemanticKind::JsNumber, + rep: NativeRep::F64, + llvm_ty: DOUBLE, + value: value.clone(), + }; + let (layout_facts, layout_note) = window_layout_facts(fact, arr_id); + ctx.record_lowered_value_with_access_mode_and_facts( + "NumericArrayIndexGet", + Some(arr_id), + "packed_f64_masked_window_load", + &lowered, + Some(BoundsState::Guarded { + guard_id: fact.guard_id.clone(), + }), + None, + Some(BufferAccessMode::CheckedNative), + None, + None, + None, + layout_facts, + Vec::new(), + false, + false, + vec![ + "index_range=static_window_guarded".to_string(), + "length_range=guarded_i32".to_string(), + layout_note, + ], + ); + Ok(value) + }, + ) } /// True when `object[index]` matches an active i32-tier masked-window fact — diff --git a/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs b/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs index e24b927ff1..0c5c390efd 100644 --- a/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs +++ b/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs @@ -73,6 +73,10 @@ pub(crate) fn lower_generic_property_get( // the receiver so a nested `a.b.c` chain keeps the inner `.b` access's more // specific location when *it* is the throwing read. crate::expr::calls::emit_call_location_at(ctx, byte_offset); + // #7640 section E audit: this helper lowers only `object`; the property + // name is compile-time data. The optional debug-location call above only + // updates TLS (`js_set_call_location`) and cannot allocate or collect, so + // there is no second user-expression window requiring an operand group. let key_idx = ctx.strings.intern(property); let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); let blk = ctx.block(); diff --git a/crates/perry-codegen/src/expr/property_set.rs b/crates/perry-codegen/src/expr/property_set.rs index d053098ea9..911013ff52 100644 --- a/crates/perry-codegen/src/expr/property_set.rs +++ b/crates/perry-codegen/src/expr/property_set.rs @@ -14,6 +14,10 @@ //! lowered by `lower_expr`) or //! [`crate::rooting::with_operands_rooted_across`] (the value is lowered by //! `lower_value_for_dynamic_property_set`, which this API cannot produce). +//! The class-field family has one deliberate split: `LocalGet` / `This` +//! receivers keep their existing zero-cost `root_reload` repair, while a +//! compound receiver uses an operand group because that pass cannot rederive +//! its phi result (#7640 section C). //! //! The migration found one arm that had no guard at all: `arr.length = f()` //! (#7637). It is the same #7154 window every sibling arm already closed, and @@ -54,6 +58,29 @@ fn canonicalize_raw_f64_numeric_store_value( ) } +/// Lower the receiver/value pair for the class-field and setter fast paths. +/// +/// `root_reload` already repairs the common bare-local / `this` receiver, and +/// keeping that path direct preserves its hot IR. A compound receiver is a call +/// result/phi with no storage the reload pass can name, so #7640 requires an +/// explicit operand group whenever the RHS can collect. The group itself keeps +/// inert RHSs byte-identical by answering `Reuse`. +fn with_class_store_operands<'f, R>( + ctx: &mut FnCtx<'f>, + object: &Expr, + value: &Expr, + body: impl FnOnce(&mut FnCtx<'f>, String, String) -> Result, +) -> Result { + if matches!(object, Expr::LocalGet(_) | Expr::This) { + let recv_box = lower_expr(ctx, object)?; + let val_double = lower_expr(ctx, value)?; + return body(ctx, recv_box, val_double); + } + rooting::with_operands_rooted(ctx, &[object, value], |ctx, vals| { + body(ctx, vals[0].clone(), vals[1].clone()) + }) +} + pub(crate) fn class_has_computed_runtime_members(ctx: &FnCtx<'_>, class_name: &str) -> bool { ctx.classes .get(class_name) @@ -172,7 +199,7 @@ pub(crate) fn try_lower_sloppy_class_field_store( // that is still `ptr addrspace(1)`-typed and live across the safepoint, // and `recv_box` crosses it as a plain `double` (a `bitcast`/`ptrtoint` // chain, dead before the call, per `function/precise_roots.rs`). The - // claim is TRUE for exactly one shape and OPEN for everything else: + // repair is deliberately split by receiver shape: // // * `object` a bare `Expr::LocalGet`/`Expr::This` — its value IS a load // out of a shadow slot, and `root_reload.rs` (#7280) re-materialises @@ -185,160 +212,159 @@ pub(crate) fn try_lower_sloppy_class_field_store( // `test-files/test_gap_gc_class_field_receiver_rooting.ts`'s // `setRawF64`/`setBoxed`/`setViaSetter` — zero hazards. // * `object` anything else — e.g. `this.target.x = allocPoint(n).x`, - // where the receiver is itself a class-field READ — is genuinely - // UNPROTECTED, and neither instrument above catches it: the receiver + // where the receiver is itself a class-field READ — cannot use that + // repair: the receiver // is a `phi` over two field-get paths, not a direct shadow-slot load, // so `root_reload` has no root to re-derive from, and // `--stale-registers`' pattern match only anchors on a direct // `load double, ptr ` source. Confirmed by hand on this exact // shape (`Holder.setOnThis` in the test above): the field-get result // register is reused, unreloaded, after `allocPoint`'s call in the - // emitted IR. Left unfixed here: rooting it unconditionally would add - // a real store+bind+reread to the common `LocalGet`/`This` case this - // comment just proved needs none, on what the issue that tracks this - // (#7640) calls "the hottest store path in the compiler" — a - // measured-cost change, not a rooting-API mechanical one, and - // deliberately left for a follow-up that can benchmark it. See - // changelog.d/7640-abc-rooting-residue.md. - let recv_box = lower_expr(ctx, object)?; - let val_double = lower_expr(ctx, value)?; - - // #7287: inside the fast clone of a #5093 class-field versioned loop, this - // store is covered by the preheader's hoisted shape check — emit the same - // inline plain-finite check + bare slot store the STRICT arm emits (see - // `lower`'s class-field arm), instead of the per-access diamond. - // - // Sound in sloppy mode for the same reason #7423 made the fast arm - // mode-independent: the preheader proved not-frozen, no per-receiver - // descriptors, matching class id and keys token, and an intact typed - // layout, and the loop's body is call-free so none of that can change while - // the clone runs. A store that reaches the raw slot could not have been - // *rejected* in either mode, so there is no sloppy/strict divergence to - // preserve. Everything else — a non-finite or NaN-boxed value — side-exits - // to the slow clone BEFORE storing, and the slow clone re-executes the whole - // iteration through this unchanged sloppy lowering. - if let Expr::LocalGet(recv_id) = object { - if let Some((fact, _)) = crate::expr::class_field_loop_fact_lookup( - &ctx.class_field_loop_facts, - *recv_id, - &class_name, - property, - ) - .filter(|(_, loop_idx)| *loop_idx == field_index) - { - let obj_ptr = fact.obj_ptr.clone(); - let side_exit_label = fact.side_exit_label.clone(); - let store_idx = ctx.new_block("class_field_loop_store.sloppy_fast"); - let store_label = ctx.block_label(store_idx); - { - let blk = ctx.block(); - let val_bits = blk.bitcast_double_to_i64(&val_double); - let finite = crate::expr::class_field_inline_guard::emit_plain_finite_number_check( - blk, &val_bits, - ); - blk.cond_br(&finite, &store_label, &side_exit_label); - } - ctx.current_block = store_idx; + // emitted IR. `with_class_store_operands` closes exactly this residual + // with an explicit operand group, while routing bare locals / `this` + // through the unchanged direct path. Its own collection predicate keeps + // a compound receiver with an inert RHS byte-identical too. + with_class_store_operands(ctx, object, value, |ctx, recv_box, val_double| { + // #7287: inside the fast clone of a #5093 class-field versioned loop, this + // store is covered by the preheader's hoisted shape check — emit the same + // inline plain-finite check + bare slot store the STRICT arm emits (see + // `lower`'s class-field arm), instead of the per-access diamond. + // + // Sound in sloppy mode for the same reason #7423 made the fast arm + // mode-independent: the preheader proved not-frozen, no per-receiver + // descriptors, matching class id and keys token, and an intact typed + // layout, and the loop's body is call-free so none of that can change while + // the clone runs. A store that reaches the raw slot could not have been + // *rejected* in either mode, so there is no sloppy/strict divergence to + // preserve. Everything else — a non-finite or NaN-boxed value — side-exits + // to the slow clone BEFORE storing, and the slow clone re-executes the whole + // iteration through this unchanged sloppy lowering. + if let Expr::LocalGet(recv_id) = object { + if let Some((fact, _)) = crate::expr::class_field_loop_fact_lookup( + &ctx.class_field_loop_facts, + *recv_id, + &class_name, + property, + ) + .filter(|(_, loop_idx)| *loop_idx == field_index) { - let header_skip = - crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); - let blk = ctx.block(); - let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); - let field_ptr = blk.gep(DOUBLE, &fields_base, &[(I64, &field_index.to_string())]); - // No `js_array_numeric_value_to_raw_f64` canonicalization is - // needed: INT32-boxed and NaN values — the only inputs it - // rewrites — cannot pass the finite check above. - // - // GC_STORE_AUDIT(POINTER_FREE): the finite check proved - // `val_double` is a genuine unboxed double, never a heap - // pointer — no edge, no write barrier. - blk.store(DOUBLE, &val_double, &field_ptr); + let obj_ptr = fact.obj_ptr.clone(); + let side_exit_label = fact.side_exit_label.clone(); + let store_idx = ctx.new_block("class_field_loop_store.sloppy_fast"); + let store_label = ctx.block_label(store_idx); + { + let blk = ctx.block(); + let val_bits = blk.bitcast_double_to_i64(&val_double); + let finite = + crate::expr::class_field_inline_guard::emit_plain_finite_number_check( + blk, &val_bits, + ); + blk.cond_br(&finite, &store_label, &side_exit_label); + } + ctx.current_block = store_idx; + { + let header_skip = + crate::target_layout::object_header_size_bytes(ctx.target_triple) + .to_string(); + let blk = ctx.block(); + let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); + let field_ptr = + blk.gep(DOUBLE, &fields_base, &[(I64, &field_index.to_string())]); + // No `js_array_numeric_value_to_raw_f64` canonicalization is + // needed: INT32-boxed and NaN values — the only inputs it + // rewrites — cannot pass the finite check above. + // + // GC_STORE_AUDIT(POINTER_FREE): the finite check proved + // `val_double` is a genuine unboxed double, never a heap + // pointer — no edge, no write barrier. + blk.store(DOUBLE, &val_double, &field_ptr); + } + return Ok(Some(val_double)); } - return Ok(Some(val_double)); } - } - - let key_idx = ctx.strings.intern(property); - let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); - let field_idx_str = field_index.to_string(); - let expected_class_id_str = expected_class_id.to_string(); - let (obj_bits, obj_handle, key_box, val_bits, expected_keys) = { - let blk = ctx.block(); - let obj_bits = blk.bitcast_double_to_i64(&recv_box); - let obj_handle = blk.and(I64, &obj_bits, POINTER_MASK_I64); - let key_box = blk.load(DOUBLE, &key_handle_global); - let val_bits = blk.bitcast_double_to_i64(&val_double); - let expected_keys = blk.load(I64, &format!("@{}", keys_global_name)); - (obj_bits, obj_handle, key_box, val_bits, expected_keys) - }; + let key_idx = ctx.strings.intern(property); + let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); + let field_idx_str = field_index.to_string(); + let expected_class_id_str = expected_class_id.to_string(); - let fast_idx = ctx.new_block("class_field_sloppy_set.fast"); - let merge_idx = ctx.new_block("class_field_sloppy_set.merge"); - let fast_label = ctx.block_label(fast_idx); - let merge_label = ctx.block_label(merge_idx); + let (obj_bits, obj_handle, key_box, val_bits, expected_keys) = { + let blk = ctx.block(); + let obj_bits = blk.bitcast_double_to_i64(&recv_box); + let obj_handle = blk.and(I64, &obj_bits, POINTER_MASK_I64); + let key_box = blk.load(DOUBLE, &key_handle_global); + let val_bits = blk.bitcast_double_to_i64(&val_double); + let expected_keys = blk.load(I64, &format!("@{}", keys_global_name)); + (obj_bits, obj_handle, key_box, val_bits, expected_keys) + }; - // Emits the shape/flags/value precheck and branches to `fast_label` on a - // hit; leaves `ctx.current_block` on the freshly created miss block. - let subclass_arms = crate::expr::class_field_inline_guard::class_field_subclass_arms( - ctx, - &class_name, - property, - field_index, - true, - ); - let _miss_label = crate::expr::class_field_inline_guard::emit_class_field_inline_precheck( - ctx, - &obj_bits, - &obj_handle, - &expected_class_id_str, - &expected_keys, - field_index, - true, - Some(&val_bits), - &fast_label, - &subclass_arms, - ); + let fast_idx = ctx.new_block("class_field_sloppy_set.fast"); + let merge_idx = ctx.new_block("class_field_sloppy_set.merge"); + let fast_label = ctx.block_label(fast_idx); + let merge_label = ctx.block_label(merge_idx); - // Miss: the strict-aware runtime with `strict = 0`, so a rejected write - // stays a silent no-op exactly as sloppy `PutValue` requires. - { - let blk = ctx.block(); - let _ = blk.call( - DOUBLE, - "js_put_value_set", - &[ - (DOUBLE, &recv_box), - (DOUBLE, &key_box), - (DOUBLE, &val_double), - (DOUBLE, &recv_box), - (I32, "0"), - ], + // Emits the shape/flags/value precheck and branches to `fast_label` on a + // hit; leaves `ctx.current_block` on the freshly created miss block. + let subclass_arms = crate::expr::class_field_inline_guard::class_field_subclass_arms( + ctx, + &class_name, + property, + field_index, + true, + ); + let _miss_label = crate::expr::class_field_inline_guard::emit_class_field_inline_precheck( + ctx, + &obj_bits, + &obj_handle, + &expected_class_id_str, + &expected_keys, + field_index, + true, + Some(&val_bits), + &fast_label, + &subclass_arms, ); - blk.br(&merge_label); - } - ctx.current_block = fast_idx; - { - // arm64_32 watchOS: the fields region starts at `size_of::()` - // past the user pointer (24 on 64-bit, 20 on ILP32) — same derivation as - // the strict arm and the runtime setter. - let header_skip = - crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); - let blk = ctx.block(); - let obj_ptr = blk.inttoptr(I64, &obj_handle); - let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); - let field_ptr = blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]); - // GC_STORE_AUDIT(POINTER_FREE): a guarded raw-f64 class slot holds - // numbers only, and the precheck rejected every value that is not a - // plain finite double, so no write barrier and no layout note are due. - let numeric_value = canonicalize_raw_f64_numeric_store_value(blk, &val_double); - blk.store(DOUBLE, &numeric_value, &field_ptr); - blk.br(&merge_label); - } + // Miss: the strict-aware runtime with `strict = 0`, so a rejected write + // stays a silent no-op exactly as sloppy `PutValue` requires. + { + let blk = ctx.block(); + let _ = blk.call( + DOUBLE, + "js_put_value_set", + &[ + (DOUBLE, &recv_box), + (DOUBLE, &key_box), + (DOUBLE, &val_double), + (DOUBLE, &recv_box), + (I32, "0"), + ], + ); + blk.br(&merge_label); + } - ctx.current_block = merge_idx; - Ok(Some(val_double)) + ctx.current_block = fast_idx; + { + // arm64_32 watchOS: the fields region starts at `size_of::()` + // past the user pointer (24 on 64-bit, 20 on ILP32) — same derivation as + // the strict arm and the runtime setter. + let header_skip = + crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); + let blk = ctx.block(); + let obj_ptr = blk.inttoptr(I64, &obj_handle); + let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); + let field_ptr = blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]); + // GC_STORE_AUDIT(POINTER_FREE): a guarded raw-f64 class slot holds + // numbers only, and the precheck rejected every value that is not a + // plain finite double, so no write barrier and no layout note are due. + let numeric_value = canonicalize_raw_f64_numeric_store_value(blk, &val_double); + blk.store(DOUBLE, &numeric_value, &field_ptr); + blk.br(&merge_label); + } + + ctx.current_block = merge_idx; + Ok(Some(val_double)) + }) } /// The boxed-slot half of [`try_lower_sloppy_class_field_store`] — P1 (#5094). @@ -381,137 +407,134 @@ fn try_lower_sloppy_class_field_boxed_store( keys_global_name: &str, class_name: &str, ) -> Result> { - // Operand order mirrors the raw-f64 arm and the strict class-field arm - // verbatim: the assignment reference is evaluated before the RHS. See - // `try_lower_sloppy_class_field_store`'s #7640-section-C note above for - // why this is safe and what actually makes it so (`root_reload.rs`, not - // a statepoint-specific mechanism) — same shape, same fix, same proof. - let recv_box = lower_expr(ctx, object)?; - let val_double = lower_expr(ctx, value)?; + // The direct local/`this` path keeps the existing root-reload repair; the + // compound path gets the explicit operand root the #7640 note above says it + // lacked. + with_class_store_operands(ctx, object, value, |ctx, recv_box, val_double| { + // Computed before the block builder is borrowed below. + let barrier_needed = !expr_produces_non_pointer_bits_by_construction(ctx, value); + let layout_note_needed = class_field_store_needs_layout_note(ctx, value); + let string_addref_needed = class_field_store_needs_string_addref(ctx, value); - // Computed before the block builder is borrowed below. - let barrier_needed = !expr_produces_non_pointer_bits_by_construction(ctx, value); - let layout_note_needed = class_field_store_needs_layout_note(ctx, value); - let string_addref_needed = class_field_store_needs_string_addref(ctx, value); - - let key_idx = ctx.strings.intern(property); - let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); - let field_idx_str = field_index.to_string(); - let expected_class_id_str = expected_class_id.to_string(); - - let (obj_bits, obj_handle, key_box, val_bits, expected_keys) = { - let blk = ctx.block(); - let obj_bits = blk.bitcast_double_to_i64(&recv_box); - let obj_handle = blk.and(I64, &obj_bits, POINTER_MASK_I64); - let key_box = blk.load(DOUBLE, &key_handle_global); - let val_bits = blk.bitcast_double_to_i64(&val_double); - let expected_keys = blk.load(I64, &format!("@{}", keys_global_name)); - (obj_bits, obj_handle, key_box, val_bits, expected_keys) - }; + let key_idx = ctx.strings.intern(property); + let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); + let field_idx_str = field_index.to_string(); + let expected_class_id_str = expected_class_id.to_string(); - let fast_idx = ctx.new_block("class_field_sloppy_set.boxed_fast"); - let merge_idx = ctx.new_block("class_field_sloppy_set.boxed_merge"); - let fast_label = ctx.block_label(fast_idx); - let merge_label = ctx.block_label(merge_idx); + let (obj_bits, obj_handle, key_box, val_bits, expected_keys) = { + let blk = ctx.block(); + let obj_bits = blk.bitcast_double_to_i64(&recv_box); + let obj_handle = blk.and(I64, &obj_bits, POINTER_MASK_I64); + let key_box = blk.load(DOUBLE, &key_handle_global); + let val_bits = blk.bitcast_double_to_i64(&val_double); + let expected_keys = blk.load(I64, &format!("@{}", keys_global_name)); + (obj_bits, obj_handle, key_box, val_bits, expected_keys) + }; - // `set_value_bits` is `Some` so the not-frozen check is emitted; - // `require_raw_f64` is false, so the plain-finite value check is not. - let subclass_arms = crate::expr::class_field_inline_guard::class_field_subclass_arms( - ctx, - class_name, - property, - field_index, - false, - ); - let _miss_label = crate::expr::class_field_inline_guard::emit_class_field_inline_precheck( - ctx, - &obj_bits, - &obj_handle, - &expected_class_id_str, - &expected_keys, - field_index, - false, - Some(&val_bits), - &fast_label, - &subclass_arms, - ); + let fast_idx = ctx.new_block("class_field_sloppy_set.boxed_fast"); + let merge_idx = ctx.new_block("class_field_sloppy_set.boxed_merge"); + let fast_label = ctx.block_label(fast_idx); + let merge_label = ctx.block_label(merge_idx); - { - let blk = ctx.block(); - let _ = blk.call( - DOUBLE, - "js_put_value_set", - &[ - (DOUBLE, &recv_box), - (DOUBLE, &key_box), - (DOUBLE, &val_double), - (DOUBLE, &recv_box), - (I32, "0"), - ], + // `set_value_bits` is `Some` so the not-frozen check is emitted; + // `require_raw_f64` is false, so the plain-finite value check is not. + let subclass_arms = crate::expr::class_field_inline_guard::class_field_subclass_arms( + ctx, + class_name, + property, + field_index, + false, ); - blk.br(&merge_label); - } - - ctx.current_block = fast_idx; - { - // arm64_32 watchOS: the fields region starts at - // `size_of::()` past the user pointer — same derivation - // as every sibling arm and the runtime setter. - let header_skip = - crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); - let (field_ptr, field_addr) = { - let blk = ctx.block(); - let obj_ptr = blk.inttoptr(I64, &obj_handle); - let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); - let field_ptr = blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]); - let field_addr = blk.ptrtoint(&field_ptr, I64); - (field_ptr, field_addr) - }; - emit_jsvalue_slot_store_pointer_tested( + let _miss_label = crate::expr::class_field_inline_guard::emit_class_field_inline_precheck( ctx, - &field_ptr, - &val_double, - &obj_handle, - &field_idx_str, - string_addref_needed, - layout_note_needed, &obj_bits, - &field_addr, - barrier_needed, - class_field_store_layout_note_is_conforming(ctx, class_name, field_index), + &obj_handle, + &expected_class_id_str, + &expected_keys, + field_index, + false, + Some(&val_bits), + &fast_label, + &subclass_arms, ); - ctx.block().br(&merge_label); - } - ctx.current_block = merge_idx; - let stored = LoweredValue { - semantic: SemanticKind::JsValue, - rep: NativeRep::JsValue, - llvm_ty: DOUBLE, - value: val_double.clone(), - }; - ctx.record_lowered_value_with_access_mode( - "ClassFieldSet", - None, - "class_field_set.sloppy_boxed_store", - &stored, - Some(BoundsState::Guarded { - guard_id: "class_field_inline_precheck".to_string(), - }), - None, - Some(BufferAccessMode::CheckedNative), - None, - false, - false, - vec![ - format!("field={}", property), - format!("field_index={}", field_idx_str), - "receiver_proof=inline_precheck_exact_class".to_string(), - "field_layout_raw_f64=false".to_string(), - "store_guard_failure=js_put_value_set_sloppy".to_string(), - ], - ); - Ok(Some(val_double)) + { + let blk = ctx.block(); + let _ = blk.call( + DOUBLE, + "js_put_value_set", + &[ + (DOUBLE, &recv_box), + (DOUBLE, &key_box), + (DOUBLE, &val_double), + (DOUBLE, &recv_box), + (I32, "0"), + ], + ); + blk.br(&merge_label); + } + + ctx.current_block = fast_idx; + { + // arm64_32 watchOS: the fields region starts at + // `size_of::()` past the user pointer — same derivation + // as every sibling arm and the runtime setter. + let header_skip = + crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); + let (field_ptr, field_addr) = { + let blk = ctx.block(); + let obj_ptr = blk.inttoptr(I64, &obj_handle); + let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); + let field_ptr = blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]); + let field_addr = blk.ptrtoint(&field_ptr, I64); + (field_ptr, field_addr) + }; + emit_jsvalue_slot_store_pointer_tested( + ctx, + &field_ptr, + &val_double, + &obj_handle, + &field_idx_str, + string_addref_needed, + layout_note_needed, + &obj_bits, + &field_addr, + barrier_needed, + class_field_store_layout_note_is_conforming(ctx, class_name, field_index), + ); + ctx.block().br(&merge_label); + } + + ctx.current_block = merge_idx; + let stored = LoweredValue { + semantic: SemanticKind::JsValue, + rep: NativeRep::JsValue, + llvm_ty: DOUBLE, + value: val_double.clone(), + }; + ctx.record_lowered_value_with_access_mode( + "ClassFieldSet", + None, + "class_field_set.sloppy_boxed_store", + &stored, + Some(BoundsState::Guarded { + guard_id: "class_field_inline_precheck".to_string(), + }), + None, + Some(BufferAccessMode::CheckedNative), + None, + false, + false, + vec![ + format!("field={}", property), + format!("field_index={}", field_idx_str), + "receiver_proof=inline_precheck_exact_class".to_string(), + "field_layout_raw_f64=false".to_string(), + "store_guard_failure=js_put_value_set_sloppy".to_string(), + ], + ); + Ok(Some(val_double)) + }) } fn lower_runtime_property_set_by_name( @@ -861,11 +884,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // undefined; we forward `value` as the expression result. // // #7640 section C: `recv_box` below is lowered before `value` - // exactly like the class-field arms, and the same split answer - // applies — see `try_lower_sloppy_class_field_store`'s note: safe - // for a `LocalGet`/`This` receiver (`root_reload.rs`, verified), - // open for a compound one (`this.target.y = allocPoint(n).x`, - // same test file). + // exactly like the class-field arms. Keep the zero-cost + // `LocalGet`/`This` path, and conditionally root a compound + // receiver across an allocating value expression. if let Some(class_name) = receiver_class_name(ctx, object) { if class_has_computed_runtime_members(ctx, &class_name) { return lower_runtime_property_set_by_name(ctx, object, property, value); @@ -880,14 +901,19 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { .unwrap_or(false); if !is_static_accessor { if let Some(fn_name) = ctx.methods.get(&setter_key).cloned() { - let recv_box = lower_expr(ctx, object)?; - let val_double = lower_expr(ctx, value)?; - let _ = ctx.block().call( - DOUBLE, - &fn_name, - &[(DOUBLE, &recv_box), (DOUBLE, &val_double)], + return with_class_store_operands( + ctx, + object, + value, + |ctx, recv_box, val_double| { + let _ = ctx.block().call( + DOUBLE, + &fn_name, + &[(DOUBLE, &recv_box), (DOUBLE, &val_double)], + ); + Ok(val_double) + }, ); - return Ok(val_double); } } // Fast path: known class instance + plain instance field. @@ -896,9 +922,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // // This is "the strict class-field arm below" the sloppy arms // in `try_lower_sloppy_class_field_store` name — same - // `recv_box`-before-`value` order, same #7640 section C - // answer (safe for `LocalGet`/`This`, open for a compound - // receiver), same evidence. + // `recv_box`-before-`value` order, same #7640 section C split: + // direct `LocalGet`/`This` stays on root-reload, while a compound + // receiver is explicitly rooted by `with_class_store_operands`. if let Some(field_index) = crate::type_analysis::class_field_global_index(ctx, &class_name, property) { @@ -906,413 +932,435 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ctx.class_ids.get(&class_name), ctx.class_keys_globals.get(&class_name).cloned(), ) { - let recv_box = lower_expr(ctx, object)?; - let val_double = lower_expr(ctx, value)?; - let key_idx = ctx.strings.intern(property); - let key_handle_global = - format!("@{}", ctx.strings.entry(key_idx).handle_global); - let site_id = emit_typed_feedback_register_site( + return with_class_store_operands( ctx, - TypedFeedbackKind::PropertySet, - property, - TypedFeedbackContract::class_field_set(), - ); - let field_idx_str = field_index.to_string(); - let expected_class_id_str = expected_class_id.to_string(); - let requires_raw_f64 = crate::type_analysis::class_field_declared_type( - ctx, - &class_name, - property, - ) - .as_ref() - .is_some_and(crate::typed_shape::type_is_raw_f64_candidate); - let requires_raw_f64_str = if requires_raw_f64 { "1" } else { "0" }; - // #5093 loop versioning: inside the fast clone of a - // class-field versioned loop, a tracked raw-f64 field - // store on the proven receiver lowers to an inline - // plain-finite value check + bare slot store on the - // preheader-cached object pointer. A value that is - // not a plain finite double (±Inf/NaN, or any NaN-box - // tag — including INT32-boxed integers) side-exits to - // the slow clone's preheader BEFORE the store, so the - // slow clone re-executes the whole iteration and - // routes the value through the runtime guard exactly - // as today (downgrade semantics preserved). - if requires_raw_f64 { - let loop_fact = match object.as_ref() { - Expr::LocalGet(recv_id) => { - crate::expr::class_field_loop_fact_lookup( - &ctx.class_field_loop_facts, - *recv_id, + object, + value, + |ctx, recv_box, val_double| { + let key_idx = ctx.strings.intern(property); + let key_handle_global = + format!("@{}", ctx.strings.entry(key_idx).handle_global); + let site_id = emit_typed_feedback_register_site( + ctx, + TypedFeedbackKind::PropertySet, + property, + TypedFeedbackContract::class_field_set(), + ); + let field_idx_str = field_index.to_string(); + let expected_class_id_str = expected_class_id.to_string(); + let requires_raw_f64 = + crate::type_analysis::class_field_declared_type( + ctx, &class_name, property, ) - .filter(|(_, loop_idx)| *loop_idx == field_index) - .map(|(fact, _)| { - (fact.obj_ptr.clone(), fact.side_exit_label.clone()) - }) - } - _ => None, - }; - if let Some((obj_ptr, side_exit_label)) = loop_fact { - let field_idx_str = field_index.to_string(); - let store_idx = ctx.new_block("class_field_loop_store.fast"); - let store_label = ctx.block_label(store_idx); - { - let blk = ctx.block(); - let val_bits = blk.bitcast_double_to_i64(&val_double); - let finite = crate::expr::class_field_inline_guard:: + .as_ref() + .is_some_and(crate::typed_shape::type_is_raw_f64_candidate); + let requires_raw_f64_str = if requires_raw_f64 { "1" } else { "0" }; + // #5093 loop versioning: inside the fast clone of a + // class-field versioned loop, a tracked raw-f64 field + // store on the proven receiver lowers to an inline + // plain-finite value check + bare slot store on the + // preheader-cached object pointer. A value that is + // not a plain finite double (±Inf/NaN, or any NaN-box + // tag — including INT32-boxed integers) side-exits to + // the slow clone's preheader BEFORE the store, so the + // slow clone re-executes the whole iteration and + // routes the value through the runtime guard exactly + // as today (downgrade semantics preserved). + if requires_raw_f64 { + let loop_fact = + match object.as_ref() { + Expr::LocalGet(recv_id) => { + crate::expr::class_field_loop_fact_lookup( + &ctx.class_field_loop_facts, + *recv_id, + &class_name, + property, + ) + .filter(|(_, loop_idx)| *loop_idx == field_index) + .map(|(fact, _)| { + ( + fact.obj_ptr.clone(), + fact.side_exit_label.clone(), + ) + }) + } + _ => None, + }; + if let Some((obj_ptr, side_exit_label)) = loop_fact { + let field_idx_str = field_index.to_string(); + let store_idx = + ctx.new_block("class_field_loop_store.fast"); + let store_label = ctx.block_label(store_idx); + { + let blk = ctx.block(); + let val_bits = blk.bitcast_double_to_i64(&val_double); + let finite = crate::expr::class_field_inline_guard:: emit_plain_finite_number_check(blk, &val_bits); - blk.cond_br(&finite, &store_label, &side_exit_label); + blk.cond_br(&finite, &store_label, &side_exit_label); + } + ctx.current_block = store_idx; + { + let header_skip = + crate::target_layout::object_header_size_bytes( + ctx.target_triple, + ) + .to_string(); + let blk = ctx.block(); + let fields_base = + blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); + let field_ptr = blk.gep( + DOUBLE, + &fields_base, + &[(I64, &field_idx_str)], + ); + // No raw-f64 canonicalization call is needed: + // INT32-boxed and NaN values — the only + // inputs `js_array_numeric_value_to_raw_f64` + // rewrites — cannot pass the finite check. + // + // GC_STORE_AUDIT(POINTER_FREE): the inline + // finite check proved `val_double` is a + // genuine (unboxed, finite) double, never a + // heap pointer — no edge, no write barrier. + blk.store(DOUBLE, &val_double, &field_ptr); + } + let stored = LoweredValue { + semantic: SemanticKind::JsNumber, + rep: NativeRep::F64, + llvm_ty: DOUBLE, + value: val_double.clone(), + }; + ctx.record_lowered_value_with_access_mode_and_facts( + "ClassFieldSet", + None, + "class_field_set.loop_raw_f64_store", + &stored, + Some(BoundsState::Guarded { + guard_id: "class_field_loop_preheader_check" + .to_string(), + }), + None, + Some(BufferAccessMode::CheckedNative), + None, + None, + None, + vec![raw_f64_layout_fact( + None, + "consumed", + "class_field_loop_preheader_check", + None, + )], + Vec::new(), + false, + false, + vec![ + format!("class={}", class_name), + format!("field={}", property), + format!("field_index={}", field_idx_str), + "receiver_proof=loop_preheader_shape_check" + .to_string(), + "field_layout=raw_f64_slot_array".to_string(), + "loop_versioning=class_field_fast_clone" + .to_string(), + "rhs_numeric_guard=inline_plain_finite_check" + .to_string(), + "store_guard_failure=side_exit_slow_restart" + .to_string(), + ], + ); + return Ok(val_double); + } } - ctx.current_block = store_idx; - { + // Representation-selection Phase 3b: shape-proven + // Ptr receiver (collectors/ptr_shape.rs) — no + // guard call, no shape diamond. Raw-f64 slots keep the + // inline plain-finite value check with a cold + // `js_class_field_set_fallback` arm (a NaN/Inf/boxed + // value must never be stored raw into a scalar-masked + // slot — the runtime setter performs the layout + // downgrade the GC scan relies on). Boxed slots store + // inline with the existing generational write barrier + // for possibly-pointer values. + // Phase 5a routes `this` here too. The freeze-family + // module-wide kill (collectors/proven_this.rs) is what + // makes a guard-free STORE through a proven `this` + // sound: unlike a Phase 3b local the receiver is + // caller-owned and therefore aliased, so a frozen or + // sealed target would otherwise silently accept a raw + // store where the spec requires a strict TypeError. + let ptr_shape_proven = ctx + .ptr_shape_receiver_fact(object.as_ref()) + .map(|fact| fact.class_name == class_name) + .unwrap_or(false); + if ptr_shape_proven { + ctx.note_ptr_shape_consumed(object.as_ref(), "ptr_shape_set"); let header_skip = crate::target_layout::object_header_size_bytes( ctx.target_triple, ) .to_string(); - let blk = ctx.block(); - let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); - let field_ptr = - blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]); - // No raw-f64 canonicalization call is needed: - // INT32-boxed and NaN values — the only - // inputs `js_array_numeric_value_to_raw_f64` - // rewrites — cannot pass the finite check. - // - // GC_STORE_AUDIT(POINTER_FREE): the inline - // finite check proved `val_double` is a - // genuine (unboxed, finite) double, never a - // heap pointer — no edge, no write barrier. - blk.store(DOUBLE, &val_double, &field_ptr); - } - let stored = LoweredValue { - semantic: SemanticKind::JsNumber, - rep: NativeRep::F64, - llvm_ty: DOUBLE, - value: val_double.clone(), - }; - ctx.record_lowered_value_with_access_mode_and_facts( - "ClassFieldSet", - None, - "class_field_set.loop_raw_f64_store", - &stored, - Some(BoundsState::Guarded { - guard_id: "class_field_loop_preheader_check".to_string(), - }), - None, - Some(BufferAccessMode::CheckedNative), - None, - None, - None, - vec![raw_f64_layout_fact( + let field_set_barrier_needed = + !expr_produces_non_pointer_bits_by_construction(ctx, value); + let (obj_bits, obj_handle, field_ptr, val_bits) = { + let blk = ctx.block(); + let obj_bits = blk.bitcast_double_to_i64(&recv_box); + let obj_handle = blk.and(I64, &obj_bits, POINTER_MASK_I64); + let obj_ptr = blk.inttoptr(I64, &obj_handle); + let fields_base = + blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); + let field_ptr = + blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]); + let val_bits = blk.bitcast_double_to_i64(&val_double); + (obj_bits, obj_handle, field_ptr, val_bits) + }; + if requires_raw_f64 { + let store_idx = ctx.new_block("ptr_shape_set.raw_store"); + let cold_idx = ctx.new_block("ptr_shape_set.downgrade"); + let merge_idx = ctx.new_block("ptr_shape_set.merge"); + let store_label = ctx.block_label(store_idx); + let cold_label = ctx.block_label(cold_idx); + let merge_label = ctx.block_label(merge_idx); + { + let blk = ctx.block(); + let finite = crate::expr::class_field_inline_guard:: + emit_plain_finite_number_check(blk, &val_bits); + blk.cond_br(&finite, &store_label, &cold_label); + } + ctx.current_block = store_idx; + { + // The finite check proved a genuine unboxed + // double (INT32-boxed and every NaN-box tag + // share the all-ones exponent), so no + // canonicalization call is needed. + let blk = ctx.block(); + // GC_STORE_AUDIT(POINTER_FREE): pointer-free + // by that proof — no GC pointer reaches the + // slot, so no write barrier. + blk.store(DOUBLE, &val_double, &field_ptr); + blk.br(&merge_label); + } + ctx.current_block = cold_idx; + { + let blk = ctx.block(); + let key_box = blk.load(DOUBLE, &key_handle_global); + let key_bits = blk.bitcast_double_to_i64(&key_box); + let key_raw = blk.and(I64, &key_bits, POINTER_MASK_I64); + blk.call_void( + "js_class_field_set_fallback", + &[ + (I64, &site_id), + (I64, &obj_bits), + (I64, &key_raw), + (DOUBLE, &val_double), + ], + ); + blk.br(&merge_label); + } + ctx.current_block = merge_idx; + } else { + // Repsel Phase 4b.1: retire the two bookkeeping + // calls that are provably dead here. + // + // The receiver being `Ptr`-proven is + // what licenses the layout-note elision. Three + // facts close it: + // + // Both are decided from the VALUE expression, + // and gated independently because they are dead + // under different conditions: the note needs + // "not a pointer", the addref only "not a heap + // string". Neither is keyed on the declared + // field type — Perry does not enforce declared + // types at runtime, so a `boolean` field can + // legitimately receive a string through an + // `any`, and a wrong addref elision there + // silently corrupts it on the next in-place + // append. + // + // `requires_raw_f64` is false on this arm, so + // the raw-f64-mask arm of `layout_note_slot` — + // the one that *must* downgrade — is + // unreachable from here. The full per-layout- + // state argument, including why a pointer store + // into a pointer-masked slot is deliberately + // NOT elided, is on + // `class_field_store_needs_layout_note`. + let layout_note_needed = + class_field_store_needs_layout_note(ctx, value); + let string_addref_needed = + class_field_store_needs_string_addref(ctx, value); + let field_addr = ctx.block().ptrtoint(&field_ptr, I64); + // #7511: whatever these three flags could not + // be proved away statically is decided by ONE + // live test of the stored bits — see + // `emit_jsvalue_slot_store_pointer_tested`. + emit_jsvalue_slot_store_pointer_tested( + ctx, + &field_ptr, + &val_double, + &obj_handle, + &field_idx_str, + string_addref_needed, + layout_note_needed, + &obj_bits, + &field_addr, + field_set_barrier_needed, + class_field_store_layout_note_is_conforming( + ctx, + &class_name, + field_index, + ), + ); + } + let (semantic, rep) = if requires_raw_f64 { + (SemanticKind::JsNumber, NativeRep::F64) + } else { + (SemanticKind::JsValue, NativeRep::JsValue) + }; + let stored = LoweredValue { + semantic, + rep, + llvm_ty: DOUBLE, + value: val_double.clone(), + }; + ctx.record_lowered_value_with_access_mode_and_facts( + "ClassFieldSet", None, - "consumed", - "class_field_loop_preheader_check", + "class_field_set.shape_proven_store", + &stored, + Some(BoundsState::Guarded { + guard_id: "ptr_shape_static_proof".to_string(), + }), None, - )], - Vec::new(), - false, - false, - vec![ - format!("class={}", class_name), - format!("field={}", property), - format!("field_index={}", field_idx_str), - "receiver_proof=loop_preheader_shape_check".to_string(), - "field_layout=raw_f64_slot_array".to_string(), - "loop_versioning=class_field_fast_clone".to_string(), - "rhs_numeric_guard=inline_plain_finite_check".to_string(), - "store_guard_failure=side_exit_slow_restart".to_string(), - ], - ); - return Ok(val_double); - } - } - // Representation-selection Phase 3b: shape-proven - // Ptr receiver (collectors/ptr_shape.rs) — no - // guard call, no shape diamond. Raw-f64 slots keep the - // inline plain-finite value check with a cold - // `js_class_field_set_fallback` arm (a NaN/Inf/boxed - // value must never be stored raw into a scalar-masked - // slot — the runtime setter performs the layout - // downgrade the GC scan relies on). Boxed slots store - // inline with the existing generational write barrier - // for possibly-pointer values. - // Phase 5a routes `this` here too. The freeze-family - // module-wide kill (collectors/proven_this.rs) is what - // makes a guard-free STORE through a proven `this` - // sound: unlike a Phase 3b local the receiver is - // caller-owned and therefore aliased, so a frozen or - // sealed target would otherwise silently accept a raw - // store where the spec requires a strict TypeError. - let ptr_shape_proven = ctx - .ptr_shape_receiver_fact(object.as_ref()) - .map(|fact| fact.class_name == class_name) - .unwrap_or(false); - if ptr_shape_proven { - ctx.note_ptr_shape_consumed(object.as_ref(), "ptr_shape_set"); - let header_skip = - crate::target_layout::object_header_size_bytes(ctx.target_triple) - .to_string(); - let field_set_barrier_needed = - !expr_produces_non_pointer_bits_by_construction(ctx, value); - let (obj_bits, obj_handle, field_ptr, val_bits) = { - let blk = ctx.block(); - let obj_bits = blk.bitcast_double_to_i64(&recv_box); - let obj_handle = blk.and(I64, &obj_bits, POINTER_MASK_I64); - let obj_ptr = blk.inttoptr(I64, &obj_handle); - let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); - let field_ptr = - blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]); - let val_bits = blk.bitcast_double_to_i64(&val_double); - (obj_bits, obj_handle, field_ptr, val_bits) - }; - if requires_raw_f64 { - let store_idx = ctx.new_block("ptr_shape_set.raw_store"); - let cold_idx = ctx.new_block("ptr_shape_set.downgrade"); - let merge_idx = ctx.new_block("ptr_shape_set.merge"); - let store_label = ctx.block_label(store_idx); - let cold_label = ctx.block_label(cold_idx); - let merge_label = ctx.block_label(merge_idx); - { - let blk = ctx.block(); - let finite = crate::expr::class_field_inline_guard:: - emit_plain_finite_number_check(blk, &val_bits); - blk.cond_br(&finite, &store_label, &cold_label); - } - ctx.current_block = store_idx; - { - // The finite check proved a genuine unboxed - // double (INT32-boxed and every NaN-box tag - // share the all-ones exponent), so no - // canonicalization call is needed. - let blk = ctx.block(); - // GC_STORE_AUDIT(POINTER_FREE): pointer-free - // by that proof — no GC pointer reaches the - // slot, so no write barrier. - blk.store(DOUBLE, &val_double, &field_ptr); - blk.br(&merge_label); + Some(BufferAccessMode::CheckedNative), + None, + None, + None, + if requires_raw_f64 { + vec![raw_f64_layout_fact( + None, + "consumed", + "ptr_shape_static_proof", + None, + )] + } else { + Vec::new() + }, + Vec::new(), + false, + false, + vec![ + format!("class={}", class_name), + format!("field={}", property), + format!("field_index={}", field_idx_str), + "receiver_proof=ptr_shape_local".to_string(), + format!("field_layout_raw_f64={}", requires_raw_f64), + ], + ); + return Ok(val_double); } - ctx.current_block = cold_idx; - { - let blk = ctx.block(); - let key_box = blk.load(DOUBLE, &key_handle_global); - let key_bits = blk.bitcast_double_to_i64(&key_box); - let key_raw = blk.and(I64, &key_bits, POINTER_MASK_I64); - blk.call_void( - "js_class_field_set_fallback", + // #5334 lever B: oversized modules full-outline the entire + // class-field-SET IC diamond (guard + fast store + + // fallback) to a single `js_class_field_set_ic(...)` call. + // This trades a call frame on the (cold, startup- + // dominated) field-set path for a large per-site IR + // reduction, so clang -O0 — which oversized modules are + // forced to (#4880) — can actually compile the module. + // Only the call's own operands are materialized (the key + // handle + expected-keys), not the inline-store scaffolding. + if crate::codegen::full_outline_ic_enabled() { + let (key_raw, expected_keys) = { + let blk = ctx.block(); + let key_box = blk.load(DOUBLE, &key_handle_global); + let key_bits = blk.bitcast_double_to_i64(&key_box); + let key_raw = blk.and(I64, &key_bits, POINTER_MASK_I64); + let expected_keys = + blk.load(I64, &format!("@{}", keys_global_name)); + (key_raw, expected_keys) + }; + ctx.block().call_void( + "js_class_field_set_ic", &[ (I64, &site_id), - (I64, &obj_bits), + (DOUBLE, &recv_box), + (I32, &expected_class_id_str), + (I64, &expected_keys), (I64, &key_raw), + (I32, &field_idx_str), (DOUBLE, &val_double), + (I32, requires_raw_f64_str), ], ); - blk.br(&merge_label); + return Ok(val_double); } - ctx.current_block = merge_idx; - } else { - // Repsel Phase 4b.1: retire the two bookkeeping - // calls that are provably dead here. + // #5093: build the guard operands once, up front, so both + // the inline shape pre-check and the guard-call fallback + // can reference them. + let (obj_bits, obj_handle, key_raw, expected_keys, val_bits) = { + let blk = ctx.block(); + let obj_bits = blk.bitcast_double_to_i64(&recv_box); + let obj_handle = blk.and(I64, &obj_bits, POINTER_MASK_I64); + let key_box = blk.load(DOUBLE, &key_handle_global); + let key_bits = blk.bitcast_double_to_i64(&key_box); + let key_raw = blk.and(I64, &key_bits, POINTER_MASK_I64); + let expected_keys = + blk.load(I64, &format!("@{}", keys_global_name)); + let val_bits = blk.bitcast_double_to_i64(&val_double); + (obj_bits, obj_handle, key_raw, expected_keys, val_bits) + }; + let fast_idx = ctx.new_block("class_field_set.fast"); + let fallback_idx = ctx.new_block("class_field_set.fallback"); + let merge_idx = ctx.new_block("class_field_set.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); + + // #5093: inline shape pre-check. On a hit this branches + // straight to the store, skipping the call; on a miss the + // guard-call path below runs unchanged. // - // The receiver being `Ptr`-proven is - // what licenses the layout-note elision. Three - // facts close it: + // #7854: this used to be gated on `requires_raw_f64`, + // leaving every BOXED declared field (`string`, a class + // type, a union — i.e. most fields of most objects) paying + // an unconditional cross-crate + // `js_typed_feedback_class_field_set_guard` call per + // store, including the synthesized + // `__AnonShape_*_constructor` that every closed-shape + // object literal runs. The stated reason — "its setter-in- + // chain handling and write barrier aren't reproduced + // inline" — is answered by + // `try_lower_sloppy_class_field_boxed_store`, which has + // taken the boxed inline precheck since #7288: the write + // barrier, layout note and string demote come from + // `emit_jsvalue_slot_store_pointer_tested` (which the + // shared `fast_label` block below calls, with the very + // same value-side predicates), NOT from the guard; and a + // setter in the chain is already refused upstream by + // `class_field_global_index`'s `accessor_in_chain`. // - // Both are decided from the VALUE expression, - // and gated independently because they are dead - // under different conditions: the note needs - // "not a pointer", the addref only "not a heap - // string". Neither is keyed on the declared - // field type — Perry does not enforce declared - // types at runtime, so a `boolean` field can - // legitimately receive a string through an - // `any`, and a wrong addref elision there - // silently corrupts it on the next in-place - // append. + // What the precheck proves is a strict subset of the + // runtime `class_field_fast_contract`: on a hit the guard + // call would have answered "fast" too, so this only + // removes a call, never changes which store happens. Every + // miss still lands on the guardcall block and the + // unchanged strict fallback, so `[[Set]]` rejection and + // descriptor dispatch are untouched. `require_raw_f64` is + // forwarded rather than hardcoded, so a boxed slot skips + // the plain-finite value test (a boxed slot accepts any + // `JSValue`) but still proves not-frozen / no per-object + // descriptors via `set_value_bits: Some`. // - // `requires_raw_f64` is false on this arm, so - // the raw-f64-mask arm of `layout_note_slot` — - // the one that *must* downgrade — is - // unreachable from here. The full per-layout- - // state argument, including why a pointer store - // into a pointer-masked slot is deliberately - // NOT elided, is on - // `class_field_store_needs_layout_note`. - let layout_note_needed = - class_field_store_needs_layout_note(ctx, value); - let string_addref_needed = - class_field_store_needs_string_addref(ctx, value); - let field_addr = ctx.block().ptrtoint(&field_ptr, I64); - // #7511: whatever these three flags could not - // be proved away statically is decided by ONE - // live test of the stored bits — see - // `emit_jsvalue_slot_store_pointer_tested`. - emit_jsvalue_slot_store_pointer_tested( - ctx, - &field_ptr, - &val_double, - &obj_handle, - &field_idx_str, - string_addref_needed, - layout_note_needed, - &obj_bits, - &field_addr, - field_set_barrier_needed, - class_field_store_layout_note_is_conforming( - ctx, - &class_name, - field_index, - ), - ); - } - let (semantic, rep) = if requires_raw_f64 { - (SemanticKind::JsNumber, NativeRep::F64) - } else { - (SemanticKind::JsValue, NativeRep::JsValue) - }; - let stored = LoweredValue { - semantic, - rep, - llvm_ty: DOUBLE, - value: val_double.clone(), - }; - ctx.record_lowered_value_with_access_mode_and_facts( - "ClassFieldSet", - None, - "class_field_set.shape_proven_store", - &stored, - Some(BoundsState::Guarded { - guard_id: "ptr_shape_static_proof".to_string(), - }), - None, - Some(BufferAccessMode::CheckedNative), - None, - None, - None, - if requires_raw_f64 { - vec![raw_f64_layout_fact( - None, - "consumed", - "ptr_shape_static_proof", - None, - )] - } else { - Vec::new() - }, - Vec::new(), - false, - false, - vec![ - format!("class={}", class_name), - format!("field={}", property), - format!("field_index={}", field_idx_str), - "receiver_proof=ptr_shape_local".to_string(), - format!("field_layout_raw_f64={}", requires_raw_f64), - ], - ); - return Ok(val_double); - } - // #5334 lever B: oversized modules full-outline the entire - // class-field-SET IC diamond (guard + fast store + - // fallback) to a single `js_class_field_set_ic(...)` call. - // This trades a call frame on the (cold, startup- - // dominated) field-set path for a large per-site IR - // reduction, so clang -O0 — which oversized modules are - // forced to (#4880) — can actually compile the module. - // Only the call's own operands are materialized (the key - // handle + expected-keys), not the inline-store scaffolding. - if crate::codegen::full_outline_ic_enabled() { - let (key_raw, expected_keys) = { - let blk = ctx.block(); - let key_box = blk.load(DOUBLE, &key_handle_global); - let key_bits = blk.bitcast_double_to_i64(&key_box); - let key_raw = blk.and(I64, &key_bits, POINTER_MASK_I64); - let expected_keys = - blk.load(I64, &format!("@{}", keys_global_name)); - (key_raw, expected_keys) - }; - ctx.block().call_void( - "js_class_field_set_ic", - &[ - (I64, &site_id), - (DOUBLE, &recv_box), - (I32, &expected_class_id_str), - (I64, &expected_keys), - (I64, &key_raw), - (I32, &field_idx_str), - (DOUBLE, &val_double), - (I32, requires_raw_f64_str), - ], - ); - return Ok(val_double); - } - // #5093: build the guard operands once, up front, so both - // the inline shape pre-check and the guard-call fallback - // can reference them. - let (obj_bits, obj_handle, key_raw, expected_keys, val_bits) = { - let blk = ctx.block(); - let obj_bits = blk.bitcast_double_to_i64(&recv_box); - let obj_handle = blk.and(I64, &obj_bits, POINTER_MASK_I64); - let key_box = blk.load(DOUBLE, &key_handle_global); - let key_bits = blk.bitcast_double_to_i64(&key_box); - let key_raw = blk.and(I64, &key_bits, POINTER_MASK_I64); - let expected_keys = blk.load(I64, &format!("@{}", keys_global_name)); - let val_bits = blk.bitcast_double_to_i64(&val_double); - (obj_bits, obj_handle, key_raw, expected_keys, val_bits) - }; - let fast_idx = ctx.new_block("class_field_set.fast"); - let fallback_idx = ctx.new_block("class_field_set.fallback"); - let merge_idx = ctx.new_block("class_field_set.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); - - // #5093: inline shape pre-check. On a hit this branches - // straight to the store, skipping the call; on a miss the - // guard-call path below runs unchanged. - // - // #7854: this used to be gated on `requires_raw_f64`, - // leaving every BOXED declared field (`string`, a class - // type, a union — i.e. most fields of most objects) paying - // an unconditional cross-crate - // `js_typed_feedback_class_field_set_guard` call per - // store, including the synthesized - // `__AnonShape_*_constructor` that every closed-shape - // object literal runs. The stated reason — "its setter-in- - // chain handling and write barrier aren't reproduced - // inline" — is answered by - // `try_lower_sloppy_class_field_boxed_store`, which has - // taken the boxed inline precheck since #7288: the write - // barrier, layout note and string demote come from - // `emit_jsvalue_slot_store_pointer_tested` (which the - // shared `fast_label` block below calls, with the very - // same value-side predicates), NOT from the guard; and a - // setter in the chain is already refused upstream by - // `class_field_global_index`'s `accessor_in_chain`. - // - // What the precheck proves is a strict subset of the - // runtime `class_field_fast_contract`: on a hit the guard - // call would have answered "fast" too, so this only - // removes a call, never changes which store happens. Every - // miss still lands on the guardcall block and the - // unchanged strict fallback, so `[[Set]]` rejection and - // descriptor dispatch are untouched. `require_raw_f64` is - // forwarded rather than hardcoded, so a boxed slot skips - // the plain-finite value test (a boxed slot accepts any - // `JSValue`) but still proves not-frozen / no per-object - // descriptors via `set_value_bits: Some`. - // - // #7861: and the shape test it emits is widened from the - // DECLARED class to that class's subclass closure. Without - // this the boxed arm #7854 just un-gated would still miss - // 100% of the time for a store in a base class's own - // constructor, where `this` is only ever a subclass. The - // arms are computed with `requires_raw_f64` rather than a - // literal, so a candidate whose declared type disagrees - // about the slot's representation is dropped. - let subclass_arms = + // #7861: and the shape test it emits is widened from the + // DECLARED class to that class's subclass closure. Without + // this the boxed arm #7854 just un-gated would still miss + // 100% of the time for a store in a base class's own + // constructor, where `this` is only ever a subclass. The + // arms are computed with `requires_raw_f64` rather than a + // literal, so a candidate whose declared type disagrees + // about the slot's representation is dropped. + let subclass_arms = crate::expr::class_field_inline_guard::class_field_subclass_arms( ctx, &class_name, @@ -1320,7 +1368,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { field_index, requires_raw_f64, ); - let _guardcall_label = + let _guardcall_label = crate::expr::class_field_inline_guard::emit_class_field_inline_precheck( ctx, &obj_bits, @@ -1333,149 +1381,156 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { &fast_label, &subclass_arms, ); - let guard_ok = ctx.block().call( - I32, - "js_typed_feedback_class_field_set_guard", - &[ - (I64, &site_id), - (DOUBLE, &recv_box), - (I32, &expected_class_id_str), - (I64, &expected_keys), - (I64, &key_raw), - (I32, &field_idx_str), - (DOUBLE, &val_double), - (I32, requires_raw_f64_str), - ], - ); - let guard_pass = ctx.block().icmp_ne(I32, &guard_ok, "0"); - ctx.block() - .cond_br(&guard_pass, &fast_label, &fallback_label); - - ctx.current_block = fast_idx; - // #5334 lever D: a value that is a non-pointer by - // construction (number / bool / undefined / null / - // comparison / arithmetic) creates no parent→child heap - // reference, so the generational write barrier is a - // semantic no-op and can be skipped. Computed before the - // block builder is borrowed below. The LAYOUT NOTE is - // kept regardless: it records the slot's pointer-ness for - // minor-scan skipping, and a non-pointer write into a - // slot that previously held a pointer is a real - // transition the GC must observe. Same soundness standard - // as the array-store barrier elision. - let field_set_barrier_needed = - !expr_produces_non_pointer_bits_by_construction(ctx, value); - // #7469: value-side elision of the addref and layout - // note on the guarded arm — computed here because the - // predicates take `&FnCtx` and the block builder is - // borrowed below. - let guarded_note_needed = class_field_store_needs_layout_note(ctx, value); - let guarded_addref_needed = - class_field_store_needs_string_addref(ctx, value); - let raw_stored_value = { - // arm64_32 watchOS: the object fields region begins at - // `size_of::()` past the user pointer — 24 on - // 64-bit, 20 on ILP32 (the trailing `keys_array` pointer is - // 4 bytes there). A hardcoded 24 writes every class field 4 - // bytes off on a 32-bit watch; the paired inline read - // (`property_get`) and the runtime setter must agree, so - // derive it from the target triple (no-op on 64-bit; see - // `target_layout`). - let header_skip = - crate::target_layout::object_header_size_bytes(ctx.target_triple) - .to_string(); - let field_ptr = { - let blk = ctx.block(); - let obj_ptr = blk.inttoptr(I64, &obj_handle); - let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); - blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]) - }; - let raw_stored_value = if requires_raw_f64 { - // Guarded raw-f64 slots are pointer-free by typed - // shape descriptor; non-number writes miss the - // guard and use the boxed setter fallback. - // GC_STORE_AUDIT(POINTER_FREE): typed raw-f64 class - // slots contain numbers only. - let blk = ctx.block(); - let numeric_value = - canonicalize_raw_f64_numeric_store_value(blk, &val_double); - blk.store(DOUBLE, &numeric_value, &field_ptr); - Some(numeric_value) - } else { - // #5334 lever D: skip the barrier when the value - // is a non-pointer by construction. #7469 extends - // the same value-expression gating to the addref - // and layout note — the Phase 4b.1 predicates are - // value-side-only proofs (see their docs: safe in - // every layout state the receiver can be in), so - // they apply on this guarded arm exactly as on - // the ptr-shape-proven arm above. The guard - // passing does not change what the VALUE can be; - // `requires_raw_f64` is false here, which is the - // precondition `class_field_store_needs_layout_note` - // documents. - // - // #7511: this is the arm the shared - // `_constructor` symbol lands on, where the - // value is an opaque function parameter and lever D - // can never fire. Whatever survives it is decided by - // ONE live test of the stored bits instead of three - // cross-crate calls that each re-ask the same - // question — see - // `emit_jsvalue_slot_store_pointer_tested`. - let field_addr = ctx.block().ptrtoint(&field_ptr, I64); - emit_jsvalue_slot_store_pointer_tested( - ctx, - &field_ptr, - &val_double, - &obj_handle, - &field_idx_str, - guarded_addref_needed, - guarded_note_needed, - &obj_bits, - &field_addr, - field_set_barrier_needed, - class_field_store_layout_note_is_conforming( - ctx, - &class_name, - field_index, - ), + let guard_ok = ctx.block().call( + I32, + "js_typed_feedback_class_field_set_guard", + &[ + (I64, &site_id), + (DOUBLE, &recv_box), + (I32, &expected_class_id_str), + (I64, &expected_keys), + (I64, &key_raw), + (I32, &field_idx_str), + (DOUBLE, &val_double), + (I32, requires_raw_f64_str), + ], ); - None - }; - ctx.block().br(&merge_label); - raw_stored_value - }; - if let Some(numeric_value) = raw_stored_value { - let stored = LoweredValue { - semantic: SemanticKind::JsNumber, - rep: NativeRep::F64, - llvm_ty: DOUBLE, - value: numeric_value.clone(), - }; - ctx.record_lowered_value_with_access_mode_and_facts( - "ClassFieldSet", - None, - "class_field_set.raw_f64_store", - &stored, - Some(BoundsState::Guarded { - guard_id: "class_field_set_guard".to_string(), - }), - None, - Some(BufferAccessMode::CheckedNative), - None, - None, - None, - vec![raw_f64_layout_fact( - None, - "consumed", - "class_field_set_guard", - None, - )], - Vec::new(), - false, - false, - vec![ + let guard_pass = ctx.block().icmp_ne(I32, &guard_ok, "0"); + ctx.block() + .cond_br(&guard_pass, &fast_label, &fallback_label); + + ctx.current_block = fast_idx; + // #5334 lever D: a value that is a non-pointer by + // construction (number / bool / undefined / null / + // comparison / arithmetic) creates no parent→child heap + // reference, so the generational write barrier is a + // semantic no-op and can be skipped. Computed before the + // block builder is borrowed below. The LAYOUT NOTE is + // kept regardless: it records the slot's pointer-ness for + // minor-scan skipping, and a non-pointer write into a + // slot that previously held a pointer is a real + // transition the GC must observe. Same soundness standard + // as the array-store barrier elision. + let field_set_barrier_needed = + !expr_produces_non_pointer_bits_by_construction(ctx, value); + // #7469: value-side elision of the addref and layout + // note on the guarded arm — computed here because the + // predicates take `&FnCtx` and the block builder is + // borrowed below. + let guarded_note_needed = + class_field_store_needs_layout_note(ctx, value); + let guarded_addref_needed = + class_field_store_needs_string_addref(ctx, value); + let raw_stored_value = { + // arm64_32 watchOS: the object fields region begins at + // `size_of::()` past the user pointer — 24 on + // 64-bit, 20 on ILP32 (the trailing `keys_array` pointer is + // 4 bytes there). A hardcoded 24 writes every class field 4 + // bytes off on a 32-bit watch; the paired inline read + // (`property_get`) and the runtime setter must agree, so + // derive it from the target triple (no-op on 64-bit; see + // `target_layout`). + let header_skip = + crate::target_layout::object_header_size_bytes( + ctx.target_triple, + ) + .to_string(); + let field_ptr = { + let blk = ctx.block(); + let obj_ptr = blk.inttoptr(I64, &obj_handle); + let fields_base = + blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); + blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]) + }; + let raw_stored_value = if requires_raw_f64 { + // Guarded raw-f64 slots are pointer-free by typed + // shape descriptor; non-number writes miss the + // guard and use the boxed setter fallback. + // GC_STORE_AUDIT(POINTER_FREE): typed raw-f64 class + // slots contain numbers only. + let blk = ctx.block(); + let numeric_value = + canonicalize_raw_f64_numeric_store_value( + blk, + &val_double, + ); + blk.store(DOUBLE, &numeric_value, &field_ptr); + Some(numeric_value) + } else { + // #5334 lever D: skip the barrier when the value + // is a non-pointer by construction. #7469 extends + // the same value-expression gating to the addref + // and layout note — the Phase 4b.1 predicates are + // value-side-only proofs (see their docs: safe in + // every layout state the receiver can be in), so + // they apply on this guarded arm exactly as on + // the ptr-shape-proven arm above. The guard + // passing does not change what the VALUE can be; + // `requires_raw_f64` is false here, which is the + // precondition `class_field_store_needs_layout_note` + // documents. + // + // #7511: this is the arm the shared + // `_constructor` symbol lands on, where the + // value is an opaque function parameter and lever D + // can never fire. Whatever survives it is decided by + // ONE live test of the stored bits instead of three + // cross-crate calls that each re-ask the same + // question — see + // `emit_jsvalue_slot_store_pointer_tested`. + let field_addr = ctx.block().ptrtoint(&field_ptr, I64); + emit_jsvalue_slot_store_pointer_tested( + ctx, + &field_ptr, + &val_double, + &obj_handle, + &field_idx_str, + guarded_addref_needed, + guarded_note_needed, + &obj_bits, + &field_addr, + field_set_barrier_needed, + class_field_store_layout_note_is_conforming( + ctx, + &class_name, + field_index, + ), + ); + None + }; + ctx.block().br(&merge_label); + raw_stored_value + }; + if let Some(numeric_value) = raw_stored_value { + let stored = LoweredValue { + semantic: SemanticKind::JsNumber, + rep: NativeRep::F64, + llvm_ty: DOUBLE, + value: numeric_value.clone(), + }; + ctx.record_lowered_value_with_access_mode_and_facts( + "ClassFieldSet", + None, + "class_field_set.raw_f64_store", + &stored, + Some(BoundsState::Guarded { + guard_id: "class_field_set_guard".to_string(), + }), + None, + Some(BufferAccessMode::CheckedNative), + None, + None, + None, + vec![raw_f64_layout_fact( + None, + "consumed", + "class_field_set_guard", + None, + )], + Vec::new(), + false, + false, + vec![ format!("class={}", class_name), format!("class_id={}", expected_class_id_str), format!("field={}", property), @@ -1485,19 +1540,19 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "field_layout=raw_f64_slot_array".to_string(), "pointer_bitmap=non_pointer".to_string(), ], - ); - ctx.record_lowered_value_with_access_mode( - "WriteBarrierElided", - None, - "write_barrier.elided_raw_f64_class_field", - &stored, - None, - None, - None, - None, - false, - false, - vec![ + ); + ctx.record_lowered_value_with_access_mode( + "WriteBarrierElided", + None, + "write_barrier.elided_raw_f64_class_field", + &stored, + None, + None, + None, + None, + false, + false, + vec![ "reason=raw_f64_class_field_pointer_free".to_string(), format!("class={}", class_name), format!("class_id={}", expected_class_id_str), @@ -1508,75 +1563,77 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "field_layout=raw_f64_slot_array".to_string(), "pointer_bitmap=non_pointer".to_string(), ], - ); - } + ); + } - ctx.current_block = fallback_idx; - let blk = ctx.block(); - // #5334 lever A: the guard already ran and FAILED in the - // entry block, so this cold arm is a pure guard-miss - // fallback. Outline the two operations it used to emit - // inline (record_fallback + by-name set) into ONE - // `js_class_field_set_fallback` call. Semantics are - // byte-identical; only the emitted IR shrinks (cold path - // → zero hot-loop cost). `obj_bits` keeps the full - // NaN-box tag; `key_raw` is POINTER_MASK-stripped — the - // same operands the two calls received. - blk.call_void( - "js_class_field_set_fallback", - &[ - (I64, &site_id), - (I64, &obj_bits), - (I64, &key_raw), - (DOUBLE, &val_double), - ], - ); - blk.br(&merge_label); - if requires_raw_f64 { - let fallback = LoweredValue { - semantic: SemanticKind::JsValue, - rep: NativeRep::JsValue, - llvm_ty: DOUBLE, - value: val_double.clone(), - }; - ctx.record_lowered_value_with_access_mode_and_facts( - "ClassFieldSet", - None, - "js_object_set_field_by_name", - &fallback, - Some(BoundsState::Unknown), - None, - Some(BufferAccessMode::DynamicFallback), - Some(MaterializationReason::RuntimeApi), - None, - None, - Vec::new(), - vec![ - raw_f64_layout_fact( + ctx.current_block = fallback_idx; + let blk = ctx.block(); + // #5334 lever A: the guard already ran and FAILED in the + // entry block, so this cold arm is a pure guard-miss + // fallback. Outline the two operations it used to emit + // inline (record_fallback + by-name set) into ONE + // `js_class_field_set_fallback` call. Semantics are + // byte-identical; only the emitted IR shrinks (cold path + // → zero hot-loop cost). `obj_bits` keeps the full + // NaN-box tag; `key_raw` is POINTER_MASK-stripped — the + // same operands the two calls received. + blk.call_void( + "js_class_field_set_fallback", + &[ + (I64, &site_id), + (I64, &obj_bits), + (I64, &key_raw), + (DOUBLE, &val_double), + ], + ); + blk.br(&merge_label); + if requires_raw_f64 { + let fallback = LoweredValue { + semantic: SemanticKind::JsValue, + rep: NativeRep::JsValue, + llvm_ty: DOUBLE, + value: val_double.clone(), + }; + ctx.record_lowered_value_with_access_mode_and_facts( + "ClassFieldSet", None, - "rejected", - "class_field_set_guard", - Some(MaterializationReason::RuntimeApi), - ), - raw_f64_layout_fact( + "js_object_set_field_by_name", + &fallback, + Some(BoundsState::Unknown), None, - "invalidated", - "runtime_api", + Some(BufferAccessMode::DynamicFallback), Some(MaterializationReason::RuntimeApi), - ), - ], - false, - false, - vec![ - format!("class={}", class_name), - format!("field={}", property), - format!("field_index={}", field_idx_str), - ], - ); - } + None, + None, + Vec::new(), + vec![ + raw_f64_layout_fact( + None, + "rejected", + "class_field_set_guard", + Some(MaterializationReason::RuntimeApi), + ), + raw_f64_layout_fact( + None, + "invalidated", + "runtime_api", + Some(MaterializationReason::RuntimeApi), + ), + ], + false, + false, + vec![ + format!("class={}", class_name), + format!("field={}", property), + format!("field_index={}", field_idx_str), + ], + ); + } - ctx.current_block = merge_idx; - return Ok(val_double); + ctx.current_block = merge_idx; + Ok(val_double) + }, + ); } } } diff --git a/crates/perry-codegen/src/expr/proven_view_access.rs b/crates/perry-codegen/src/expr/proven_view_access.rs index 34becde97a..7e8efa0a31 100644 --- a/crates/perry-codegen/src/expr/proven_view_access.rs +++ b/crates/perry-codegen/src/expr/proven_view_access.rs @@ -229,6 +229,10 @@ pub(crate) fn try_lower_proven_view_checked_f64_load( let Some((id, view)) = proven_view_for(ctx, object, index) else { return Ok(None); }; + // #7640 section E audit: `proven_view_for` only reads compile-time facts; + // no receiver value or backing-store pointer has been materialized yet. + // Lower both user expressions first, then load `data_slot` below, so even + // a collecting proven index/value leaves no movable or raw address live. let idx_i32 = lower_expr_as_i32(ctx, index)?; let (data_ptr, len) = load_data_and_len(ctx, &view); diff --git a/crates/perry-codegen/src/expr/ptr_numarray_access.rs b/crates/perry-codegen/src/expr/ptr_numarray_access.rs index 3140eff20c..cca729ee7c 100644 --- a/crates/perry-codegen/src/expr/ptr_numarray_access.rs +++ b/crates/perry-codegen/src/expr/ptr_numarray_access.rs @@ -15,6 +15,7 @@ use crate::nanbox::POINTER_MASK_I64; use crate::native_value::{ BoundsProof, BoundsState, BufferAccessMode, LoweredValue, NativeRep, SemanticKind, }; +use crate::rooting; use crate::types::{DOUBLE, I1, I32, I64}; use super::{lower_expr, lower_expr_as_i32, raw_f64_layout_fact, FnCtx}; @@ -115,16 +116,26 @@ pub(crate) fn try_lower_num_array_guard_free_get( return Ok(None); }; if num_array_index_statically_in_bounds(ctx, &fact, index) { - let arr_box = lower_expr(ctx, &Expr::LocalGet(*arr_id))?; - let idx_i32 = lower_expr_as_i32(ctx, index)?; - return Ok(Some(lower_num_array_guard_free_get( + // #7640 section E: an `int_range_expr` proof may come from a nested + // typed-array read or a registered clamp call. Those indexes can lower + // user code, so retain the receiver across the native-i32 lowering and + // derive its raw handle only from the re-read. + return rooting::with_operands_rooted_across( ctx, - *arr_id, - &arr_box, - &idx_i32, - &fact, - BoundsProof::MinLength, - ))); + &[object], + &[index], + |ctx| lower_expr_as_i32(ctx, index), + |ctx, vals, idx_i32| { + Ok(Some(lower_num_array_guard_free_get( + ctx, + *arr_id, + &vals[0], + &idx_i32, + &fact, + BoundsProof::MinLength, + ))) + }, + ); } if let Expr::LocalGet(idx_id) = index { if ctx @@ -191,31 +202,44 @@ pub(crate) fn try_lower_num_array_guard_free_set( if !statically && bounded_slot.is_none() { return Ok(None); } - // JS evaluation order: target ref → key → value. - let arr_box = lower_expr(ctx, &Expr::LocalGet(*arr_id))?; - let (idx_i32, proof) = if statically { - (lower_expr_as_i32(ctx, index)?, BoundsProof::MinLength) - } else { - ( - ctx.block().load(I32, &bounded_slot.unwrap()), - BoundsProof::LoopGuard, - ) - }; - let val_double = lower_expr(ctx, value)?; - { - let blk = ctx.block(); - let arr_bits = blk.bitcast_double_to_i64(&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): canonical raw-f64 store under the - // `Ptr` local proof — never a GC pointer, no barrier, no - // layout note, and no length bump (in-bounds ⇒ length unchanged). - blk.store(DOUBLE, &val_double, &element_ptr); - } + // JS evaluation order: target ref → key → value. `int_range_expr` + // admits collecting index shapes, and a canonical numeric RHS may itself + // be a call. Keep the receiver across both custom-representation lowerings; + // safe loop counters/literals still emit no root traffic. + let bounded_slot = bounded_slot.clone(); + let (val_double, proof) = rooting::with_operands_rooted_across( + ctx, + &[object], + &[index, value], + |ctx| { + let (idx_i32, proof) = if statically { + (lower_expr_as_i32(ctx, index)?, BoundsProof::MinLength) + } else { + ( + ctx.block() + .load(I32, bounded_slot.as_ref().expect("bounded slot")), + BoundsProof::LoopGuard, + ) + }; + let val_double = lower_expr(ctx, value)?; + Ok((idx_i32, proof, val_double)) + }, + |ctx, vals, (idx_i32, proof, val_double)| { + let blk = ctx.block(); + let arr_bits = blk.bitcast_double_to_i64(&vals[0]); + 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): canonical raw-f64 store under the + // `Ptr` local proof — never a GC pointer, no barrier, no + // layout note, and no length bump (in-bounds ⇒ length unchanged). + blk.store(DOUBLE, &val_double, &element_ptr); + Ok((val_double, proof)) + }, + )?; let stored = LoweredValue { semantic: SemanticKind::JsNumber, rep: NativeRep::F64, diff --git a/test-files/test_gap_7640_computed_key_windows.ts b/test-files/test_gap_7640_computed_key_windows.ts index 485497362b..8001c2b537 100644 --- a/test-files/test_gap_7640_computed_key_windows.ts +++ b/test-files/test_gap_7640_computed_key_windows.ts @@ -69,3 +69,22 @@ const u8 = new Uint8Array([4, 5, 6, 7]); let sum = 0; for (let i = 0; i < u8.length; i++) sum += u8[i]; console.log(sum); + +// --- typed-array numeric-expression windows ------------------------------- +// A numeric return type is a dispatch hint, not an effect proof: both helpers +// allocate before producing the key/value. This covers the width-aware and +// Uint8Array numeric fallbacks plus the native-store pointer-ordering audit. +function taIndex(): number { + alloc(200); + return 1; +} +function taValue(): number { + alloc(200); + return 41; +} +const i32 = new Int32Array([3, 4, 5]); +i32[taIndex()] = taValue(); +console.log(i32[taIndex()], i32[0], i32[2]); + +u8[taIndex()] = taValue(); +console.log(u8[taIndex()], u8[0], u8[2]); diff --git a/test-files/test_gap_gc_class_field_receiver_rooting.ts b/test-files/test_gap_gc_class_field_receiver_rooting.ts index fde24e5b93..8ebe2ef398 100644 --- a/test-files/test_gap_gc_class_field_receiver_rooting.ts +++ b/test-files/test_gap_gc_class_field_receiver_rooting.ts @@ -18,22 +18,19 @@ // IR, so it protects the shadow (`PERRY_RS4GC=0`) and native // (`PERRY_RS4GC=1`, default) backends identically. // * `object` a compound receiver — a class-field READ, `this.target.x = …` -// (`Runner.run` below) — FALSE. The receiver register is the `phi` result -// of a class-field GET, not a direct shadow-slot load, so `root_reload` -// has nothing to re-derive from, and it is reused unreloaded after the -// RHS's allocating call in the emitted IR (confirmed by hand). This shape -// stays genuinely open — see the note in `property_set.rs` and -// changelog.d/ for why it is not fixed in the same change (the fix is a -// measured-cost one on a hot path, the finding is not). +// (`Runner.run` below) — `root_reload` cannot help because the receiver is +// the `phi` result of a class-field GET, not a direct root-slot load. #7640 +// now gives only this shape an explicit operand root when the RHS can +// collect. Bare locals / `this` keep the zero-extra-root path above, and a +// compound receiver with an inert RHS also emits no root traffic. // // This test is the permanent corpus member for both halves: it exercises the // receiver-then-allocating-value shape on plain field (raw-f64 and boxed), // an accessor (`set` method) dispatch, and the compound `this.target.field =` // shape, under enough allocation pressure (`PERRY_GC_MOVING_LOOP_POLLS=1` // back-edge polls) that a moving minor is reachable inside the RHS, and -// asserts the stored values are never stale. The safe arms pin the finding; -// the `Runner.run` arm keeps the open shape in the corpus for when an -// instrument that can see it exists. +// asserts the stored values are never stale. The direct arms pin the existing +// zero-cost repair; `Runner.run` pins the explicit compound-receiver repair. // // Verified directly against `scripts/gc_root_dominance_check.py` on this // exact shape (both `--stale-registers` and `--statepoints`, both lowerings): From 59a99c814e6d3aee8e029b28ede74ca358bb3aa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 13 Aug 2026 07:23:47 +0200 Subject: [PATCH 2/2] docs: add changelog for #8013 --- changelog.d/8013-computed-access-rooting.md | 26 +++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 changelog.d/8013-computed-access-rooting.md diff --git a/changelog.d/8013-computed-access-rooting.md b/changelog.d/8013-computed-access-rooting.md new file mode 100644 index 0000000000..4a48a325a6 --- /dev/null +++ b/changelog.d/8013-computed-access-rooting.md @@ -0,0 +1,26 @@ +**Fixed: remaining computed reads and writes could reuse GC-stale receivers, keys, and backing pointers (#7640).** + +Several typed-array, numeric-array, and generic computed-access paths evaluated +a receiver, then evaluated a key or value that could run user code and trigger +a moving collection, and finally consumed the receiver from its original LLVM +register. The same audit found typed-array stores deriving raw backing pointers +before an allocating RHS, and the array-growth path issuing its write barrier +against the pre-growth handle even when the helper returned a replacement. + +The remaining read/write operands now use selective rooting and are re-read +after collecting expressions. Masked-window and `Ptr` native paths +use the custom-lowering form of the same scope; raw backing pointers are loaded +only after the RHS; and reallocating array stores shade through the returned +live head. Literal keys, loop counters, and other proven non-collecting windows +still emit no temporary-root traffic. + +Class-field stores now distinguish their two receiver shapes explicitly. Bare +locals and `this` retain the existing zero-cost `root_reload` repair, while a +compound receiver such as `this.target.x` is conditionally rooted across an +allocating RHS because its phi result cannot be re-derived from a local root. + +IR regressions assert the typed-array read/write groups, the erased-receiver +store, and the realloc-path barrier operand. Both end-to-end fixtures remain +byte-identical with Node 26.5.1. The 143-source shadow and native/statepoint GC +corpora report zero dominance, unrooted-allocation, or statepoint hazards and +catch all 40 seeded violations in each lowering.