From 71271d795522acb4eaf3f96964273eb7b5c92012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 12:11:58 +0200 Subject: [PATCH 1/7] fix(codegen): evaluate arr.push's receiver before its argument (#7634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ES2024 evaluates the MemberExpression `arr.push` to a Reference before the argument list, so the push lands on the array `arr` named at that moment. Both arms of expr/array_push.rs lowered the argument first and read the receiver afterwards, so an argument that rebound the receiver redirected the push onto its replacement. Gated on the divergence being observable: `push_receiver_is_rebindable` is the as-if test, so the hot shape (a plain local nothing else can reach) keeps its historical order, its inline tiers and its `Reuse` verdict, and emits no rooting IR. When it fires, the spec fix and the rooting fix are one change — the receiver becomes an operand of `with_operands_rooted_across`. The reorder alone is not sufficient: every fast tier publishes the reallocated head back into the binding unconditionally, which lands on the wrong array once the argument may have rebound it. The spec-ordered arm guards its write-back on the binding still naming the array that was pushed onto. --- changelog.d/7690-push-receiver-order.md | 54 ++ crates/perry-codegen/src/expr/array_push.rs | 611 +++++++++++++----- .../test_gap_7634_push_receiver_order.ts | 80 +++ 3 files changed, 597 insertions(+), 148 deletions(-) create mode 100644 changelog.d/7690-push-receiver-order.md create mode 100644 test-files/test_gap_7634_push_receiver_order.ts diff --git a/changelog.d/7690-push-receiver-order.md b/changelog.d/7690-push-receiver-order.md new file mode 100644 index 0000000000..b546d60ec1 --- /dev/null +++ b/changelog.d/7690-push-receiver-order.md @@ -0,0 +1,54 @@ +### `arr.push(f())` evaluates the receiver before the argument again (#7634) + +ES2024 evaluates the `MemberExpression` `arr.push` to a Reference **before** the +argument list, so the push lands on the array `arr` named at that moment. Both +arms of `crates/perry-codegen/src/expr/array_push.rs` lowered the argument first +and read the receiver afterwards, so an argument that rebound the receiver +redirected the push onto its replacement: + +```ts +let a: number[] = [1]; +function f(): number { a = [9]; return 2; } +a.push(f()); +console.log(JSON.stringify(a)); // node: [9] perry: [9,2] +``` + +`arr.push(...g())` diverged the same way. Both now match `node 26.5.1` +byte-for-byte, along with `push`'s own result (the new length of the array it +pushed onto) and the aliasing case where another binding keeps the array the +push landed on. Covered by `test-files/test_gap_7634_push_receiver_order.ts`. + +**The fix is gated on the divergence being observable, and that is the point.** +A blanket reorder makes the receiver live across the argument on *every* push — +so every `rows.push({...})` and `out.push(f(x))` would gain a temp-root +push/re-read/truncate, on what #7511 measured as the hottest store family in the +compiler. `push_receiver_is_rebindable` is the as-if test: the two orders name +the same array unless the argument assigns the receiver's id itself, or the +binding is **boxed** (`collect_boxed_vars`' rule is "captured AND mutated", so a +captured-but-never-assigned array stays on the fast path) or a module global +*and* the argument can reach a collection point. When it answers `false` — the +hot shape — the historical lowering is kept with its inline tiers and its +`Reuse` verdict, and the emitted IR is unchanged: a `--trace llvm` of a +1000-iteration `out.push(mk(i))` loop plus a captured `rows.push(i * 2)` arrow +contains zero `call i32 @js_gc_temp_root_push` and zero spec-order blocks. + +When it answers `true`, the spec fix and the rooting fix are one change: the +receiver becomes an operand of `rooting::with_operands_rooted_across`, rooted +before the argument and re-read after it. `operand_protection` supplies `Root` +rather than `Reload` for exactly the reason this bug exists — re-deriving a +local or a module global would observe the argument's assignment. + +**One thing the issue did not anticipate:** the reorder alone is not sufficient. +Every fast tier publishes the reallocated array head back into the binding +unconditionally, and once the argument may have rebound that binding the store +lands on the wrong array (`a.push(f())` would overwrite `[9]` with the grown +`[1,2]`). The spec-ordered arm therefore skips the inline tiers and guards its +write-back on the binding still naming the array that was pushed onto; when it +does not, the store is skipped and aliases stay valid through the forwarding +pointer `js_array_push_f64` installs (issue #233) — the same mechanism that +already keeps `const x = a; a.push(1)` correct. + +The five-way storage write-back chain (boxed capture / boxed local / capture +slot / local alloca / module global, with #5459's fall-through) was duplicated +between the two arms and is now one `emit_push_writeback` shared by three +callers. diff --git a/crates/perry-codegen/src/expr/array_push.rs b/crates/perry-codegen/src/expr/array_push.rs index f5db05e263..78778818f9 100644 --- a/crates/perry-codegen/src/expr/array_push.rs +++ b/crates/perry-codegen/src/expr/array_push.rs @@ -15,26 +15,41 @@ //! [`crate::rooting::with_operands_rooted`], which is a documentation change //! rather than a repair: the group's window is empty, so it emits nothing. //! -//! ## Why `Expr::ArrayPush` has no window — and what that rests on +//! ## Two orders, and the as-if test that picks between them (#7634) //! -//! Both arms lower the **pushed value first and the receiver second**, and the -//! receiver is `Expr::LocalGet`, i.e. a load from the local's alloca / box / -//! module global. A load taken *after* the value's arbitrary user code observes -//! whatever an evacuating cycle wrote back into that storage, so there is no -//! stale register to repair and `operand_protection` would answer `Reuse`. -//! Everything the arms emit below that point — `js_array_push_f64`, -//! `js_array_concat`, the header probes, `js_gc_note_slot_layout`, -//! `js_write_barrier_slot`, `js_array_length` — either consumes the pointer it -//! is handed or cannot re-enter user code, so no value crosses a moving window. +//! The historical arms lower the **pushed value first and the receiver +//! second**, and the receiver is `Expr::LocalGet`, i.e. a load from the local's +//! alloca / box / module global. A load taken *after* the value's arbitrary +//! user code observes whatever an evacuating cycle wrote back into that +//! storage, so there is no stale register to repair and `operand_protection` +//! answers `Reuse`. Everything the arms emit below that point — +//! `js_array_push_f64`, `js_array_concat`, the header probes, +//! `js_gc_note_slot_layout`, `js_write_barrier_slot`, `js_array_length` — +//! either consumes the pointer it is handed or cannot re-enter user code, so no +//! value crosses a moving window. //! -//! That safety is a **consequence of an evaluation order the spec does not -//! permit**, which is what this audit found: `a.push(f())` must push onto the -//! array `a.push` resolved *before* `f` ran, and perry pushes onto whatever `a` -//! names afterwards. Reported separately rather than fixed here — the fix is to -//! lower the receiver first and carry it across the value with -//! `with_operands_rooted_across`, which turns a today-free arm into one that -//! roots on every pointer-valued push, and this slice is a behaviour-preserving -//! refactor with no mandate to measure that. +//! That safety was a **consequence of an evaluation order the spec does not +//! permit**: ES2024 evaluates the `MemberExpression` `a.push` to a Reference +//! before the argument list, so `a.push(f())` must push onto the array `a` +//! named *before* `f` ran. Perry pushed onto whatever `a` named afterwards. +//! +//! The repair is not a blanket reorder, because a blanket reorder makes the +//! receiver live across the argument on **every** push and buys an observable +//! difference on almost none of them. [`push_receiver_is_rebindable`] is the +//! as-if test: unless the argument can rebind the receiver's binding — it +//! assigns the id itself, or the binding is boxed (captured *and* mutated) or a +//! module global and the argument can reach a collection point — the two orders +//! name the same array and the historical lowering is kept, tiers, register +//! numbering and all. `an_unreachable_binding_keeps_the_historical_order_and_ +//! roots_nothing` pins that. +//! +//! When the test does fire, the spec fix and the rooting fix are one change +//! (#7634's own framing) and [`lower_array_push_spec_order`] is both: the +//! receiver is an operand of `with_operands_rooted_across`, rooted before the +//! argument and re-read after it. That arm also drops the inline fast tiers on +//! purpose — they publish the reallocated head back into the binding +//! unconditionally, and once the argument may have rebound it that store lands +//! on the wrong array. use anyhow::{anyhow, Result}; use perry_hir::Expr; @@ -91,6 +106,87 @@ fn emit_array_box_length(ctx: &mut FnCtx<'_>, array_box: &str, value_discarded: emit_array_handle_length(ctx, &array_handle, false) } +/// Publish a (possibly reallocated) array head back into whichever storage +/// backs `array_id`. +/// +/// Extracted verbatim from `Expr::ArrayPush`'s generic tail in #7634 so that +/// the spread arm and the spec-order arm share one copy: three sites emitting +/// the same five-way storage chain is three places for the #5459 fall-through +/// to be got wrong. The two early `return`s are the boxed cases — they must NOT +/// also take the capture-slot store below, which would clobber the box pointer +/// in the capture slot with the array pointer, so the next push would treat the +/// array as the box and silently lose the realloc write-back. +/// +/// `what` names the caller for the "local not in scope" diagnostic. +fn emit_push_writeback( + ctx: &mut FnCtx<'_>, + array_id: u32, + new_box: &str, + what: &str, +) -> Result<()> { + // Boxed var takes priority: write through the box so every closure sharing + // the box sees the new pointer. + if ctx.boxed_vars.contains(&array_id) { + // Captured-through-closure boxed var. + if let Some(&capture_idx) = ctx.closure_captures.get(&array_id) { + let closure_ptr = + super::current_closure_ptr_value(ctx, &format!("{what} boxed captured"))?; + let idx_str = capture_idx.to_string(); + let blk = ctx.block(); + let box_ptr = blk.call( + I64, + "js_closure_get_capture_bits", + &[(I64, &closure_ptr), (I32, &idx_str)], + ); + let new_bits = blk.bitcast_double_to_i64(new_box); + blk.call_void("js_box_set_bits", &[(I64, &box_ptr), (I64, &new_bits)]); + // Gen-GC Phase C2: the realloc'd array head is a (possibly young) + // heap pointer stored into an existing box — barrier the box parent + // so a minor GC can't miss it. + emit_write_barrier(ctx, &box_ptr, &new_bits); + return Ok(()); + } else if let Some(slot) = ctx.locals.get(&array_id).cloned() { + let blk = ctx.block(); + let box_ptr = blk.load(I64, &slot); + let new_bits = blk.bitcast_double_to_i64(new_box); + blk.call_void("js_box_set_bits", &[(I64, &box_ptr), (I64, &new_bits)]); + // Gen-GC Phase C2: barrier the box parent (see capture path). + emit_write_barrier(ctx, &box_ptr, &new_bits); + return Ok(()); + } + // #5459: `array_id` is in `boxed_vars` but has no box location in THIS + // context — it's a module-level global accessed directly from a nested + // function (the load path read `@global`, not a box-get). Returning here + // would skip the realloc write-back entirely, so the relocated array + // header is never stored to the registered GC-root global slot: the old + // head is freed on the next GC and the global dangles (use-after-free / + // corrupted length). Fall through to the module-global store-back below + // instead of returning. + } + if let Some(&capture_idx) = ctx.closure_captures.get(&array_id) { + let closure_ptr = super::current_closure_ptr_value(ctx, &format!("{what} captured"))?; + let idx_str = capture_idx.to_string(); + let new_bits = ctx.block().bitcast_double_to_i64(new_box); + ctx.block().call_void( + "js_closure_set_capture_bits", + &[(I64, &closure_ptr), (I32, &idx_str), (I64, &new_bits)], + ); + // Gen-GC Phase C2: the realloc'd array head stored into the closure + // capture is a (possibly young) heap pointer — barrier the closure + // parent. + emit_write_barrier(ctx, &closure_ptr, &new_bits); + } else if let Some(slot) = ctx.locals.get(&array_id).cloned() { + ctx.block().store(DOUBLE, new_box, &slot); + } else if let Some(global_name) = ctx.module_globals.get(&array_id).cloned() { + let g_ref = format!("@{}", global_name); + // GC_STORE_AUDIT(ROOT): module global array slot is a registered mutable GC root. + emit_root_nanbox_store_on_block(ctx.block(), new_box, &g_ref); + } else { + return Err(anyhow!("{}({}): local not in scope", what, array_id)); + } + Ok(()) +} + fn lower_array_push_value( ctx: &mut FnCtx<'_>, value: &Expr, @@ -124,6 +220,174 @@ fn lower_array_push_value( Ok((value_double, Some(value_bits))) } +/// Does evaluating `arg` **rebind** `array_id` — assign the binding a different +/// array — rather than merely mutate the array it names? +/// +/// Only a direct `LocalSet` / `Update` on that id, in this expression, can do +/// it without the binding also being reachable from other code. A closure +/// LITERAL inside the argument is answered `true` without looking inside: +/// `walk_expr_children` deliberately does not descend into a closure body, and +/// "a closure that assigns it is boxed, so [`push_receiver_is_rebindable`]'s +/// other clause catches it" is a second-order argument this predicate should +/// not rest on. +fn expr_rebinds_local(arg: &Expr, array_id: u32) -> bool { + match arg { + Expr::LocalSet(id, _) | Expr::Update { id, .. } => { + if *id == array_id { + return true; + } + } + Expr::Closure { .. } => return true, + _ => {} + } + let mut found = false; + perry_hir::walker::walk_expr_children(arg, &mut |child| { + found = found || expr_rebinds_local(child, array_id); + }); + found +} + +/// Is the receiver binding of `arr.push(arg)` reachable for **rebinding** while +/// `arg` is evaluated? (#7634) +/// +/// ES2024 evaluates the `MemberExpression` `arr.push` to a Reference *before* +/// the argument list, so the push must land on the array `arr` named at that +/// moment. Perry lowers the argument first and reads the receiver afterwards, +/// which observes whatever `arg` left in the binding. That divergence is +/// **unobservable unless the binding can change**, and this predicate is the +/// as-if test: when it answers `false`, the two orders name the same array and +/// the historical lowering — with its inline fast tiers and its `Reuse` verdict +/// from `operand_protection` — is kept byte for byte. +/// +/// It answers `true` in exactly three cases: +/// +/// * `arg` rebinds the id itself ([`expr_rebinds_local`]); +/// * the binding is **boxed** — `collect_boxed_vars`' rule is "captured AND +/// mutated", so a boxed id is one some closure or the enclosing function +/// assigns. A captured-but-never-assigned array is not boxed and stays on +/// the fast path, which is why `items.forEach(x => rows.push(f(x)))` costs +/// nothing; +/// * the binding is a module global, which any function can assign. +/// +/// The last two additionally require the argument to be able to reach a +/// collection point at all (`any_operand_may_collect`, the same window +/// predicate every rooting decision in this crate consults). `g.push(1)` on a +/// module global cannot rebind anything: there is no call to do it in. +fn push_receiver_is_rebindable(ctx: &FnCtx<'_>, array_id: u32, arg: &Expr) -> bool { + if expr_rebinds_local(arg, array_id) { + return true; + } + let reachable_from_other_code = + ctx.boxed_vars.contains(&array_id) || ctx.module_globals.contains_key(&array_id); + reachable_from_other_code && rooting::any_operand_may_collect(ctx, std::iter::once(arg)) +} + +/// The spec-ordered push: receiver first, rooted across the argument. +/// +/// Reached only when [`push_receiver_is_rebindable`] says the order is +/// observable, which is also exactly when the receiver acquires a rooting +/// window — the receiver is now live across arbitrary user code, so it is an +/// operand group rather than a post-argument load. The two are one change +/// (#7634's own framing) and `operand_protection` supplies the `Root`: a local +/// or a module global is deliberately NOT `Reload`-able, because re-deriving it +/// would observe the argument's assignment, which is the bug. +/// +/// It deliberately does **not** take any of the inline fast tiers. Those all +/// publish the reallocated head back into the binding unconditionally, and once +/// the argument may have rebound the binding that store lands on the wrong +/// array: `a.push(f())` with `f` setting `a = [9]` would overwrite `[9]` with +/// the grown `[1,2]`. Here the write-back is guarded on the binding still +/// naming the array that was pushed onto; when it does not, the store is simply +/// skipped and aliases stay valid through the forwarding pointer +/// `js_array_push_f64` installs (issue #233), exactly as they do for +/// `const x = a; a.push(1)`. +fn lower_array_push_spec_order( + ctx: &mut FnCtx<'_>, + array_id: u32, + array_expr: &Expr, + value: &Expr, + layout_note_needed: bool, + write_barrier_needed: bool, + value_discarded: bool, +) -> Result { + rooting::with_operands_rooted_across( + ctx, + std::slice::from_ref(&array_expr), + std::slice::from_ref(&value), + |ctx| lower_array_push_value(ctx, value, layout_note_needed, write_barrier_needed), + |ctx, vals, (v, _v_bits)| { + let recv_box = vals[0].clone(); + // The binding as it stands NOW. Equal to `recv_box` unless the + // argument rebound it. + let cur_box = lower_expr(ctx, array_expr)?; + let blk = ctx.block(); + let recv_bits = blk.bitcast_double_to_i64(&recv_box); + let cur_bits = blk.bitcast_double_to_i64(&cur_box); + let still_bound = blk.icmp_eq(I64, &cur_bits, &recv_bits); + let recv_handle = unbox_to_i64(blk, &recv_box); + let new_handle = blk.call( + I64, + "js_array_push_f64", + &[(I64, &recv_handle), (DOUBLE, &v)], + ); + let new_box = nanbox_pointer_inline(blk, &new_handle); + + let wb_idx = ctx.new_block("apush.spec.writeback"); + let done_idx = ctx.new_block("apush.spec.done"); + let wb_label = ctx.block_label(wb_idx); + let done_label = ctx.block_label(done_idx); + ctx.block().cond_br(&still_bound, &wb_label, &done_label); + + ctx.current_block = wb_idx; + emit_push_writeback(ctx, array_id, &new_box, "ArrayPush")?; + ctx.block().br(&done_label); + + ctx.current_block = done_idx; + Ok(emit_array_handle_length(ctx, &new_handle, value_discarded)) + }, + ) +} + +/// [`lower_array_push_spec_order`] for `arr.push(...src)`. +fn lower_array_push_spread_spec_order( + ctx: &mut FnCtx<'_>, + array_id: u32, + array_expr: &Expr, + source: &Expr, + value_discarded: bool, +) -> Result { + rooting::with_operands_rooted(ctx, &[array_expr, source], |ctx, vals| { + let recv_box = vals[0].clone(); + let src_box = vals[1].clone(); + let cur_box = lower_expr(ctx, array_expr)?; + let blk = ctx.block(); + let recv_bits = blk.bitcast_double_to_i64(&recv_box); + let cur_bits = blk.bitcast_double_to_i64(&cur_box); + let still_bound = blk.icmp_eq(I64, &cur_bits, &recv_bits); + let dst_handle = unbox_to_i64(blk, &recv_box); + let src_handle = unbox_to_i64(blk, &src_box); + let new_handle = blk.call( + I64, + "js_array_concat", + &[(I64, &dst_handle), (I64, &src_handle)], + ); + let new_box = nanbox_pointer_inline(blk, &new_handle); + + let wb_idx = ctx.new_block("apushspread.spec.writeback"); + let done_idx = ctx.new_block("apushspread.spec.done"); + let wb_label = ctx.block_label(wb_idx); + let done_label = ctx.block_label(done_idx); + ctx.block().cond_br(&still_bound, &wb_label, &done_label); + + ctx.current_block = wb_idx; + emit_push_writeback(ctx, array_id, &new_box, "ArrayPushSpread")?; + ctx.block().br(&done_label); + + ctx.current_block = done_idx; + Ok(emit_array_handle_length(ctx, &new_handle, value_discarded)) + }) +} + pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, value_discarded: bool) -> Result { match expr { Expr::ArrayPush { array_id, value } => { @@ -159,6 +423,23 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, value_discarded: bool) -> let value_is_numeric = is_numeric_expr(ctx, value); let require_numeric_layout = value_is_numeric && expr_has_numeric_pointer_free_array_layout(ctx, &array_expr); + // #7634: spec order (receiver Reference, then argument) is only + // observable when the argument can rebind the receiver. When it + // can, take the rooted spec-ordered arm; when it cannot — the hot + // shape, `out.push(f(x))` over a plain local — the historical + // argument-then-receiver order names the same array and every tier + // below keeps the IR it has always emitted. + if push_receiver_is_rebindable(ctx, *array_id, value) { + return lower_array_push_spec_order( + ctx, + *array_id, + &array_expr, + value, + layout_note_needed, + write_barrier_needed, + value_discarded, + ); + } let (v, v_bits) = lower_array_push_value(ctx, value, layout_note_needed, write_barrier_needed)?; let arr_box = lower_expr(ctx, &array_expr)?; @@ -629,76 +910,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, value_discarded: bool) -> &[(I64, &arr_handle), (DOUBLE, &v)], ); let new_box = nanbox_pointer_inline(blk, &new_handle); - // Write back to whichever storage backs the local. - // Boxed var takes priority: write through the box so - // every closure sharing the box sees the new pointer. - if ctx.boxed_vars.contains(array_id) { - // Captured-through-closure boxed var. - if let Some(&capture_idx) = ctx.closure_captures.get(array_id) { - let closure_ptr = - super::current_closure_ptr_value(ctx, "ArrayPush boxed captured")?; - let idx_str = capture_idx.to_string(); - let blk = ctx.block(); - let box_ptr = blk.call( - I64, - "js_closure_get_capture_bits", - &[(I64, &closure_ptr), (I32, &idx_str)], - ); - let new_bits = blk.bitcast_double_to_i64(&new_box); - blk.call_void("js_box_set_bits", &[(I64, &box_ptr), (I64, &new_bits)]); - // Gen-GC Phase C2: the realloc'd array head is a (possibly - // young) heap pointer stored into an existing box — barrier - // the box parent so a minor GC can't miss it. - emit_write_barrier(ctx, &box_ptr, &new_bits); - // The capture slot holds the BOX pointer; the box content is - // the shared storage every closure sees. Return here — do NOT - // fall through to the `closure_set_capture_bits` store below, - // which would clobber the box pointer in the capture slot with - // the array pointer, so the next push would treat the array as - // the box and silently lose the realloc write-back. - return Ok(emit_array_handle_length(ctx, &new_handle, value_discarded)); - } else if let Some(slot) = ctx.locals.get(array_id).cloned() { - let blk = ctx.block(); - let box_ptr = blk.load(I64, &slot); - let new_bits = blk.bitcast_double_to_i64(&new_box); - blk.call_void("js_box_set_bits", &[(I64, &box_ptr), (I64, &new_bits)]); - // Gen-GC Phase C2: barrier the box parent (see capture path). - emit_write_barrier(ctx, &box_ptr, &new_bits); - // The slot holds the BOX pointer — the box is the shared - // storage. Return so the slot keeps pointing at the box (see - // the captured branch above). - return Ok(emit_array_handle_length(ctx, &new_handle, value_discarded)); - } - // #5459: `array_id` is in `boxed_vars` but has no box location in - // THIS context — it's a module-level global accessed directly from - // a nested function (the load path read `@global`, not a box-get). - // Returning here would skip the realloc write-back entirely, so the - // relocated array header is never stored to the registered GC-root - // global slot: the old head is freed on the next GC and the global - // dangles (use-after-free / corrupted length). Fall through to the - // module-global store-back below instead of returning. - } - if let Some(&capture_idx) = ctx.closure_captures.get(array_id) { - let closure_ptr = super::current_closure_ptr_value(ctx, "ArrayPush captured")?; - let idx_str = capture_idx.to_string(); - let new_bits = ctx.block().bitcast_double_to_i64(&new_box); - ctx.block().call_void( - "js_closure_set_capture_bits", - &[(I64, &closure_ptr), (I32, &idx_str), (I64, &new_bits)], - ); - // Gen-GC Phase C2: the realloc'd array head stored into the - // closure capture is a (possibly young) heap pointer — barrier - // the closure parent. - emit_write_barrier(ctx, &closure_ptr, &new_bits); - } else if let Some(slot) = ctx.locals.get(array_id).cloned() { - ctx.block().store(DOUBLE, &new_box, &slot); - } else if let Some(global_name) = ctx.module_globals.get(array_id).cloned() { - let g_ref = format!("@{}", global_name); - // GC_STORE_AUDIT(ROOT): module global array slot is a registered mutable GC root. - emit_root_nanbox_store_on_block(ctx.block(), &new_box, &g_ref); - } else { - return Err(anyhow!("ArrayPush({}): local not in scope", array_id)); - } + emit_push_writeback(ctx, *array_id, &new_box, "ArrayPush")?; Ok(emit_array_handle_length(ctx, &new_handle, value_discarded)) } @@ -713,6 +925,17 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, value_discarded: bool) -> // `array_id`. Issue #248. Expr::ArrayPushSpread { array_id, source } => { let array_expr = Expr::LocalGet(*array_id); + // #7634, same as `Expr::ArrayPush`: spec order is only observable + // when the source can rebind the destination binding. + if push_receiver_is_rebindable(ctx, *array_id, source) { + return lower_array_push_spread_spec_order( + ctx, + *array_id, + &array_expr, + source, + value_discarded, + ); + } // The operand pair, stated through the API instead of by statement // order. The window is EMPTY and stays empty: the only thing lowered // after `source` is the receiver, which is a slot read, so @@ -730,66 +953,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, value_discarded: bool) -> &[(I64, &dst_handle), (I64, &src_handle)], ); let new_box = nanbox_pointer_inline(blk, &new_handle); - if ctx.boxed_vars.contains(array_id) { - if let Some(&capture_idx) = ctx.closure_captures.get(array_id) { - let closure_ptr = super::current_closure_ptr_value( - ctx, - "ArrayPushSpread boxed captured", - )?; - let idx_str = capture_idx.to_string(); - let blk = ctx.block(); - let box_ptr = blk.call( - I64, - "js_closure_get_capture_bits", - &[(I64, &closure_ptr), (I32, &idx_str)], - ); - let new_bits = blk.bitcast_double_to_i64(&new_box); - blk.call_void("js_box_set_bits", &[(I64, &box_ptr), (I64, &new_bits)]); - // Gen-GC Phase C2: the realloc'd array head is a (possibly - // young) heap pointer stored into an existing box — barrier - // the box parent so a minor GC can't miss it. - emit_write_barrier(ctx, &box_ptr, &new_bits); - // Box content is the shared storage; the capture slot must keep - // pointing at the box. Return so we don't fall through to the - // capture-slot store, which would clobber the box pointer (see - // the matching note in `Expr::ArrayPush`). - return Ok(emit_array_handle_length(ctx, &new_handle, value_discarded)); - } else if let Some(slot) = ctx.locals.get(array_id).cloned() { - let blk = ctx.block(); - let box_ptr = blk.load(I64, &slot); - let new_bits = blk.bitcast_double_to_i64(&new_box); - blk.call_void("js_box_set_bits", &[(I64, &box_ptr), (I64, &new_bits)]); - // Gen-GC Phase C2: barrier the box parent (see capture path). - emit_write_barrier(ctx, &box_ptr, &new_bits); - return Ok(emit_array_handle_length(ctx, &new_handle, value_discarded)); - } - // #5459: in `boxed_vars` but no box location here — a module-level - // global accessed directly from a nested function. Fall through to - // the module-global store-back so the relocated head reaches the - // GC-root slot (see the matching note in `Expr::ArrayPush`). - } - if let Some(&capture_idx) = ctx.closure_captures.get(array_id) { - let closure_ptr = - super::current_closure_ptr_value(ctx, "ArrayPushSpread captured")?; - let idx_str = capture_idx.to_string(); - let new_bits = ctx.block().bitcast_double_to_i64(&new_box); - ctx.block().call_void( - "js_closure_set_capture_bits", - &[(I64, &closure_ptr), (I32, &idx_str), (I64, &new_bits)], - ); - // Gen-GC Phase C2: the realloc'd array head stored into the - // closure capture is a (possibly young) heap pointer — barrier - // the closure parent. - emit_write_barrier(ctx, &closure_ptr, &new_bits); - } else if let Some(slot) = ctx.locals.get(array_id).cloned() { - ctx.block().store(DOUBLE, &new_box, &slot); - } else if let Some(global_name) = ctx.module_globals.get(array_id).cloned() { - let g_ref = format!("@{}", global_name); - // GC_STORE_AUDIT(ROOT): module global array slot is a registered mutable GC root. - emit_root_nanbox_store_on_block(ctx.block(), &new_box, &g_ref); - } else { - return Err(anyhow!("ArrayPushSpread({}): local not in scope", array_id)); - } + emit_push_writeback(ctx, *array_id, &new_box, "ArrayPushSpread")?; Ok(emit_array_handle_length(ctx, &new_handle, value_discarded)) }) } @@ -806,6 +970,157 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, value_discarded: bool) -> } } +/// #7634: the receiver-order gate, and the two IR shapes it selects between. +#[cfg(test)] +mod receiver_order_tests { + use super::expr_rebinds_local; + use perry_hir::types::Type; + use perry_hir::{Expr, Function, Module as HirModule, Stmt}; + + /// A one-function module whose body is `let a = []; a.push(); return a;`. + fn push_ir(value: Expr) -> String { + let mut hir = HirModule::new("apush_receiver_order_test"); + hir.functions.push(Function { + id: 0, + name: "pushes".to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Any, + body: vec![ + Stmt::Let { + id: 0, + name: "a".to_string(), + ty: Type::Any, + mutable: true, + init: Some(Expr::Array(Vec::new())), + }, + Stmt::Expr(Expr::ArrayPush { + array_id: 0, + value: Box::new(value), + }), + Stmt::Return(Some(Expr::LocalGet(0))), + ], + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }); + let opts = crate::CompileOptions { + emit_ir_only: true, + ..Default::default() + }; + let bytes = crate::compile_module(&hir, opts).expect("test module compiles"); + String::from_utf8(bytes).expect("LLVM IR is UTF-8") + } + + /// Call sites only — the module's `declare` line names the symbol too, and + /// counting it would compare 1 against 0 forever. + fn temp_root_pushes(ir: &str) -> usize { + ir.matches("call i32 @js_gc_temp_root_push").count() + } + + /// An allocating argument that CANNOT reach the receiver's binding leaves + /// the historical argument-then-receiver order in place: no rooting IR, no + /// spec-order blocks, and therefore none of the cost #7634 worried about. + /// + /// The assertion is one-sided on purpose. It cannot pass vacuously: the + /// same IR is required to still contain the push's own inline tier + /// (`apush.nofwd`), so an empty or failed compile fails here rather than + /// reporting "no roots found". + #[test] + fn an_unreachable_binding_keeps_the_historical_order_and_roots_nothing() { + let ir = push_ir(Expr::Object(vec![("v".to_string(), Expr::Number(1.0))])); + assert!( + ir.contains("apush.nofwd"), + "the push must still take its inline tier, or this test proves nothing:\n{ir}" + ); + assert!( + !ir.contains("apush.spec."), + "a plain local nothing else can reach must NOT take the spec-ordered arm:\n{ir}" + ); + assert_eq!( + temp_root_pushes(&ir), + 0, + "the hot push shape must gain no temp root:\n{ir}" + ); + } + + /// An argument that assigns the receiver's own binding takes the + /// spec-ordered arm: the receiver is lowered FIRST, rooted across the + /// argument, and the realloc write-back is guarded on the binding still + /// naming the array that was pushed onto. + #[test] + fn an_argument_that_rebinds_the_receiver_takes_the_spec_ordered_arm() { + let ir = push_ir(Expr::Sequence(vec![ + Expr::LocalSet(0, Box::new(Expr::Array(vec![Expr::Number(9.0)]))), + Expr::Number(2.0), + ])); + assert!( + ir.contains("apush.spec.writeback"), + "the rebinding argument must take the spec-ordered arm:\n{ir}" + ); + assert!( + ir.contains("apush.spec.done"), + "the spec-ordered arm must join back through its own merge block:\n{ir}" + ); + // The guard is what stops the grown OLD array being published into a + // binding the argument has already pointed somewhere else. + let guard = ir + .lines() + .find(|l| l.contains("br i1") && l.contains("label %apush.spec.writeback")) + .unwrap_or_else(|| panic!("no guarded write-back branch in:\n{ir}")); + let cond = guard + .split_whitespace() + .nth(2) + .and_then(|c| c.strip_suffix(',')) + .unwrap_or_else(|| panic!("cannot read the branch condition from {guard:?}")); + let def = ir + .lines() + .find(|l| l.trim_start().starts_with(&format!("{cond} = "))) + .unwrap_or_else(|| panic!("no definition of {cond} in:\n{ir}")); + assert!( + def.contains("icmp eq i64"), + "the write-back must be guarded on the binding still holding the pushed array, \ + got {def:?}" + ); + } + + /// The predicate itself: a write nested inside the argument counts, a write + /// to a DIFFERENT local does not, and a closure literal is answered + /// conservatively because `walk_expr_children` does not descend into one. + #[test] + fn rebinding_predicate_sees_nested_writes_only_for_the_right_local() { + let nested = Expr::Binary { + op: perry_hir::BinaryOp::Add, + left: Box::new(Expr::Number(1.0)), + right: Box::new(Expr::LocalSet(7, Box::new(Expr::Number(2.0)))), + }; + assert!(expr_rebinds_local(&nested, 7)); + assert!(!expr_rebinds_local(&nested, 8)); + assert!(expr_rebinds_local( + &Expr::Update { + id: 7, + op: perry_hir::UpdateOp::Increment, + prefix: false, + }, + 7 + )); + assert!(!expr_rebinds_local( + &Expr::Call { + callee: Box::new(Expr::LocalGet(3)), + args: vec![Expr::Number(1.0)], + type_args: Vec::new(), + byte_offset: 0, + }, + 7 + )); + } +} + #[cfg(test)] mod parent_gate_tests { use perry_hir::types::Type; diff --git a/test-files/test_gap_7634_push_receiver_order.ts b/test-files/test_gap_7634_push_receiver_order.ts new file mode 100644 index 0000000000..662e4edf9a --- /dev/null +++ b/test-files/test_gap_7634_push_receiver_order.ts @@ -0,0 +1,80 @@ +// #7634: `arr.push(f())` must push onto the array `arr.push` resolved to +// BEFORE the argument ran. ES2024 evaluates the MemberExpression to a +// Reference first, so an argument that rebinds `arr` cannot redirect the push. +// +// Only observable when the receiver's binding is reachable for writing while +// the argument is evaluated — a module global, a captured-and-mutated local, +// or a direct assignment inside the argument itself. Each of those is a case +// below; the plain-local case at the end pins that the historical order is +// kept where it is unobservable. + +// --- module global, plain push --------------------------------------------- +let a: number[] = [1]; +function f(): number { + a = [9]; + return 2; +} +a.push(f()); +console.log(JSON.stringify(a)); + +// --- module global, spread push -------------------------------------------- +let b: number[] = [1]; +function g(): number[] { + b = [9]; + return [2, 3]; +} +b.push(...g()); +console.log(JSON.stringify(b)); + +// --- captured-and-mutated local -------------------------------------------- +function capturedLocal(): string { + let c: number[] = [1]; + const rebind = (): number => { + c = [9]; + return 2; + }; + c.push(rebind()); + return JSON.stringify(c); +} +console.log(capturedLocal()); + +// --- the argument assigns the binding directly ------------------------------ +function selfAssigning(): string { + let d: number[] = [1]; + d.push(((): number => 2)()); + d.push((d = [9], 3)); + return JSON.stringify(d); +} +console.log(selfAssigning()); + +// --- the push still lands, and the discarded array still gets it ------------ +let e: number[] = [1]; +let keep: number[] = []; +function swap(): number { + keep = e; + e = [9]; + return 2; +} +e.push(swap()); +console.log(JSON.stringify(e), JSON.stringify(keep)); + +// --- the result of `push` is the NEW LENGTH of the array pushed onto -------- +let h: number[] = [1, 2, 3]; +function rebindH(): number { + h = []; + return 4; +} +console.log(h.push(rebindH())); +console.log(JSON.stringify(h)); + +// --- unobservable: a plain local nothing else can reach --------------------- +function plainLocal(): string { + const p: number[] = [1]; + p.push(two()); + p.push(...[3, 4]); + return JSON.stringify(p); +} +function two(): number { + return 2; +} +console.log(plainLocal()); From fb9bd923a6167802aabadf32a5d2197b26bb4832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 12:38:22 +0200 Subject: [PATCH 2/7] fix(codegen): root a[i]++ / o.f++'s result across the write (#7628) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The member read-modify-write arms move to RootedGroup, which re-reads at any number of caller-chosen points — the combinator #7628 asked for, already built by #7615 slice 6. The operand half the issue filed is NOT a live bug: with the per-use re-reads collapsed back to one, and with PropertyUpdate's receiver root removed entirely, the emitted IR is unchanged — root_reload (#7280) rematerialises the slot load at each use, including through the ptrtoint + POINTER_MASK handle derivation. Kept anyway (free, and it drops the dependence on a pass with a documented side condition), documented as belt-and-braces, and its two tests are named as pipeline assertions. The repair is the RESULT: for a BigInt element js_to_numeric / js_numeric_step return a heap BigIntHeader, and the value the expression yields is live across a user setter as a bare call result with no slot to reload from. adopt_emitted closes it, gated on is_provably_not_bigint so a typed-array update pays nothing. instance_misc1.rs was 4 lines under the 2000-line cap, so the two arms move to expr/member_update.rs. --- changelog.d/7691-member-update-rooting.md | 49 +++ .../perry-codegen/src/expr/instance_misc1.rs | 269 ++----------- .../src/expr/issue7628_rooting_tests.rs | 307 +++++++++++++++ .../perry-codegen/src/expr/member_update.rs | 363 ++++++++++++++++++ crates/perry-codegen/src/expr/mod.rs | 3 + .../src/expr/slice8_rooting_tests.rs | 6 +- .../test_gap_7628_index_update_rooted.ts | 62 +++ 7 files changed, 819 insertions(+), 240 deletions(-) create mode 100644 changelog.d/7691-member-update-rooting.md create mode 100644 crates/perry-codegen/src/expr/issue7628_rooting_tests.rs create mode 100644 crates/perry-codegen/src/expr/member_update.rs create mode 100644 test-files/test_gap_7628_index_update_rooted.ts diff --git a/changelog.d/7691-member-update-rooting.md b/changelog.d/7691-member-update-rooting.md new file mode 100644 index 0000000000..9ed9bbe1c2 --- /dev/null +++ b/changelog.d/7691-member-update-rooting.md @@ -0,0 +1,49 @@ +### `a[i]++` / `o.f++`: the result is a precise root, and the operand half was not a bug (#7628) + +The member read-modify-write arms consume their operands at four calls with +collection points between them: + +```text +old = js_dyn_index_get(obj, idx) ; a getter / Proxy trap +old_num = js_to_numeric(old) ; a valueOf +new = js_numeric_step(old_num, s) ; allocates only (#7198) + js_dyn_index_set(obj, idx, new) ; a setter / Proxy trap +``` + +#7628 filed the group-wide single re-read as a live #7154 and asked for a +per-use-re-read combinator. **Slice 6 had already built one** — `RootedGroup` is +one scope re-readable at any number of caller-chosen points — so both arms use +it and no new primitive arrives with this caller. + +**The operand half turned out not to be a live bug, and the sabotage arm is how +that was established rather than argued.** Collapsing the per-use re-reads back +to one — and, for `PropertyUpdate`, removing the receiver's root outright — +leaves the emitted IR unchanged in the relevant respect: `root_reload` (#7280) +rematerialises the slot load at every use a collection point can reach, +*including* through the `ptrtoint` + `and POINTER_MASK` handle derivation that +#7280's own taxonomy lists as case (a), the class it cannot repair. That entry +is about a raw handle a helper *returns*, not one masked out of a NaN-boxed +value the pass has spilled. The per-use re-reads are kept because they cost +nothing and remove the dependence on a pass carrying a documented side +condition, but they are documented as belt-and-braces, and the two tests that +cover them are named as pipeline assertions rather than lowering assertions. + +**The repair is the result.** For a BigInt element `js_to_numeric` / +`js_numeric_step` hand back a heap `BigIntHeader`, and whichever value the +expression yields — `old_num` for postfix, `new` for prefix — is live across the +write, i.e. across a user setter, as a bare call result with **no slot** for +`root_reload` to reload from. That is the taxonomy's case (d). +`RootedGroup::adopt_emitted` closes it, gated on `is_provably_not_bigint` so a +typed-array `ta[i]++` keeps the IR it had. The gate's counterfactual is measured +rather than assumed: the typed-array arm's returned register is produced *above* +the write, the same shape as the unrooted lowering. + +`test-files/test_gap_7628_index_update_rooted.ts` pins the semantics the repair +must not perturb (both fixities, BigInt elements, a `valueOf` receiver, the +lodash `countBy` shape, once-only index evaluation) byte-for-byte against node +26.5.1. `expr/issue7628_rooting_tests.rs` carries the IR-ordering assertions and +records both sabotage arms. + +The two arms moved to `crates/perry-codegen/src/expr/member_update.rs`; +`instance_misc1.rs` was 4 lines under the 2000-line cap and the change pushed it +over. diff --git a/crates/perry-codegen/src/expr/instance_misc1.rs b/crates/perry-codegen/src/expr/instance_misc1.rs index 64c643ceb3..b3f38c0463 100644 --- a/crates/perry-codegen/src/expr/instance_misc1.rs +++ b/crates/perry-codegen/src/expr/instance_misc1.rs @@ -31,25 +31,40 @@ //! `arr.splice(...)`, `Array.from(it, fn)`, `Object.groupBy` / `Map.groupBy`, //! `s.match(re)` / `s.matchAll(re)`, `JSON.parse(t, reviver)` and `a[i]++`. //! -//! ## Deliberately NOT closed here +//! ## `a[i]++` / `o.f++`: what #7628 asked for, and what it turned out to be //! -//! `Expr::IndexUpdate` (`a[i]++`) still holds its re-read receiver and index -//! across `js_dyn_index_get`, `js_to_numeric` and `js_numeric_step` — three -//! calls that can each run user code (a getter, a `valueOf`) — before -//! `js_dyn_index_set` consumes them. Closing that needs a *per-use* re-read -//! inside the body, which is a different combinator from the group-wide one -//! this campaign has (`RootedOperands::reread_one` is the raw-API shape it would -//! wrap). Per the template's rule, that combinator should arrive with the slice -//! that needs it rather than ahead of one. Filed separately; the operand-to- -//! operand half IS closed here. +//! The read-modify-write arms consume their operands at four calls with +//! collection points between them, and #7628 filed the single group-wide +//! re-read as a live #7154. It asked for a per-use-re-read combinator; slice 6 +//! had already built one (`RootedGroup`), so both arms use it and no new +//! primitive arrived with this caller. +//! +//! **But the operand half was not a live bug, and the sabotage arm is how that +//! was established rather than argued.** Collapsing the per-use re-reads back +//! to one — and, for `PropertyUpdate`, removing the receiver's root outright — +//! leaves the emitted IR unchanged in the relevant respect: `root_reload` +//! (#7280) rematerialises the slot load at every use a collection point can +//! reach, *including* through the `ptrtoint` + `and POINTER_MASK` handle +//! derivation that #7280's own taxonomy files as case (a). The per-use re-reads +//! are kept because they are free (the pass emits them anyway) and they stop +//! these arms depending on a pass with a documented side condition, but they +//! are not the repair. `expr/issue7628_rooting_tests.rs` records the measurement +//! and names those two tests as pipeline assertions, not lowering assertions. +//! +//! The repair is the **result**: for a BigInt element, `js_to_numeric` / +//! `js_numeric_step` hand back a heap `BigIntHeader`, and the one the +//! expression yields is live across `js_dyn_index_set` — a user setter — as a +//! bare call result with no slot for `root_reload` to reload from. That is the +//! taxonomy's case (d), and `RootedGroup::adopt_emitted` closes it, gated on +//! `is_provably_not_bigint` so a typed-array element pays nothing. use anyhow::Result; -use perry_hir::{BinaryOp, Expr, WithSetFallback}; +use perry_hir::{Expr, WithSetFallback}; use crate::nanbox::{double_literal, i64_literal, POINTER_MASK_I64}; use crate::rooting; use crate::type_analysis::is_string_expr; -use crate::types::{DOUBLE, I1, I32, I64, I8, PTR}; +use crate::types::{DOUBLE, I1, I32, I64, PTR}; use super::{ emit_root_nanbox_store_on_block, emit_shadow_slot_bind_for_local, emit_string_literal_global, @@ -1516,231 +1531,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { }) } - // -------- obj.field++ / obj.field-- (PropertyUpdate) -------- - // Lowered as: load → fadd/fsub 1.0 → store. Same as the - // Update variant but for a property instead of a local. - Expr::PropertyUpdate { - object, - property, - op, - prefix, - } => { - // Scalar replacement fast path: load → fadd/fsub 1.0 → store - // on the field's alloca, no heap traffic. - if let Expr::LocalGet(id) = object.as_ref() { - if let Some(slot) = ctx - .scalar_replaced - .get(id) - .and_then(|fs| fs.get(property.as_str())) - .cloned() - { - let blk = ctx.block(); - let old = blk.load(DOUBLE, &slot); - let old_num = blk.call(DOUBLE, "js_number_coerce", &[(DOUBLE, &old)]); - let new = match op { - BinaryOp::Sub => blk.fsub(&old_num, "1.0"), - _ => blk.fadd(&old_num, "1.0"), - }; - blk.store(DOUBLE, &new, &slot); - return Ok(if *prefix { new } else { old_num }); - } - } - if let Expr::This = object.as_ref() { - if let Some(slot) = ctx - .scalar_ctor_target - .last() - .and_then(|tid| ctx.scalar_replaced.get(tid)) - .and_then(|fs| fs.get(property.as_str())) - .cloned() - { - let blk = ctx.block(); - let old = blk.load(DOUBLE, &slot); - let old_num = blk.call(DOUBLE, "js_number_coerce", &[(DOUBLE, &old)]); - let new = match op { - BinaryOp::Sub => blk.fsub(&old_num, "1.0"), - _ => blk.fadd(&old_num, "1.0"), - }; - blk.store(DOUBLE, &new, &slot); - return Ok(if *prefix { new } else { old_num }); - } - } - // Representation-selection Phase 3b: `o.f++` on a shape-proven - // Ptr local whose field is numeric-proven — bare - // load/fadd/store at the fixed offset, no by-name runtime calls. - // The store keeps the raw-slot plain-finite discipline (an - // Inf-crossing update side-exits to the by-name setter, which - // performs the layout downgrade the GC scan relies on). - // (Phase 5a's proven `this` never claims numeric fields, so this - // site remains Phase-3b-local-only in practice.) - { - let fact = ctx.ptr_shape_receiver_fact(object.as_ref()).cloned(); - { - if let Some(fact) = fact { - if fact.numeric_fields.contains(property.as_str()) { - if let Some(field_index) = - crate::type_analysis::class_field_global_index( - ctx, - &fact.class_name, - property, - ) - { - ctx.note_ptr_shape_consumed(object.as_ref(), "ptr_shape_update"); - let recv_box = lower_expr(ctx, object)?; - let field_idx_str = field_index.to_string(); - let header_skip = crate::target_layout::object_header_size_bytes( - ctx.target_triple, - ) - .to_string(); - let (obj_handle, field_ptr, old, new) = { - 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 old = blk.load(DOUBLE, &field_ptr); - let new = match op { - BinaryOp::Sub => blk.fsub(&old, "1.0"), - _ => blk.fadd(&old, "1.0"), - }; - (obj_handle, field_ptr, old, new) - }; - let store_idx = ctx.new_block("ptr_shape_update.raw_store"); - let cold_idx = ctx.new_block("ptr_shape_update.downgrade"); - let merge_idx = ctx.new_block("ptr_shape_update.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 new_bits = blk.bitcast_double_to_i64(&new); - let finite = crate::expr::class_field_inline_guard:: - emit_plain_finite_number_check(blk, &new_bits); - blk.cond_br(&finite, &store_label, &cold_label); - } - ctx.current_block = store_idx; - { - // Reached only when the finite check above - // proved `new`'s exponent is NOT all-ones; - // every NaN-box tag (INT32/STRING/POINTER/ - // BIGINT) has an all-ones exponent. - let blk = ctx.block(); - // GC_STORE_AUDIT(POINTER_FREE): a genuine - // unboxed double by the proof above, never - // a GC pointer — no edge, so no barrier. - blk.store(DOUBLE, &new, &field_ptr); - blk.br(&merge_label); - } - ctx.current_block = cold_idx; - { - let key_idx = ctx.strings.intern(property); - let key_handle_global = - format!("@{}", ctx.strings.entry(key_idx).handle_global); - 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_handle = blk.and(I64, &key_bits, POINTER_MASK_I64); - blk.call_void( - "js_object_set_field_by_name", - &[(I64, &obj_handle), (I64, &key_handle), (DOUBLE, &new)], - ); - blk.br(&merge_label); - } - ctx.current_block = merge_idx; - return Ok(if *prefix { new } else { old }); - } - } - } - } - } - let obj_box = lower_expr(ctx, object)?; - let key_idx = ctx.strings.intern(property); - let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); - let blk = ctx.block(); - let obj_bits = blk.bitcast_double_to_i64(&obj_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_handle = blk.and(I64, &key_bits, POINTER_MASK_I64); - let old = blk.call( - DOUBLE, - "js_object_get_field_by_name_f64", - &[(I64, &obj_handle), (I64, &key_handle)], - ); - // ToNumeric + Type(old)::add/sub(old, unit): a BigInt field stays a - // BigInt (`var x = {y:0n}; ++x.y === 1n`), not the Number `1`. Mirrors - // the identifier `Expr::Update` path. #4918 prefix/postfix bigint. - let old_num = blk.call(DOUBLE, "js_to_numeric", &[(DOUBLE, &old)]); - let step_arg = match op { - BinaryOp::Sub => "0", - _ => "1", - }; - let new = blk.call( - DOUBLE, - "js_numeric_step", - &[(DOUBLE, &old_num), (I32, step_arg)], - ); - blk.call_void( - "js_object_set_field_by_name", - &[(I64, &obj_handle), (I64, &key_handle), (DOUBLE, &new)], - ); - Ok(if *prefix { new } else { old_num }) - } - - // -------- arr[idx]++ / arr[idx]-- / ++arr[idx] / --arr[idx] -------- - // - // Issue #957: lodash's `countBy` uses `++result[key]` which previously - // bailed `expression IndexUpdate not yet supported` and stubbed the - // entire module, leaving `import _ from "lodash"` resolving to - // undefined. Lower as a tag-aware read+modify+write through the - // `js_dyn_index_get` / `js_dyn_index_set` runtime helpers — they - // dispatch by gc_type at runtime, so the same emission works for - // arrays, plain objects, and TypedArrays without static type - // knowledge. `object` and `index` lower once into SSA registers so - // side effects are not re-evaluated. - Expr::IndexUpdate { - object, - index, - op, - prefix, - } => { - // #7615 slice 2 closes the operand-to-operand half only: the - // receiver was live across the index's own lowering. The three - // helpers below (`js_dyn_index_get`, `js_to_numeric`, - // `js_numeric_step`) can each re-enter user code, so `obj_box` and - // `idx_box` are still held across collection points before - // `js_dyn_index_set` consumes them. Closing that needs a per-use - // re-read inside the body rather than one group-wide re-read; see - // the module header. - rooting::with_operands_rooted(ctx, &[object, index], |ctx, vals| { - let (obj_box, idx_box) = (vals[0].clone(), vals[1].clone()); - let blk = ctx.block(); - let old = blk.call( - DOUBLE, - "js_dyn_index_get", - &[(DOUBLE, &obj_box), (DOUBLE, &idx_box)], - ); - // ToNumeric + numeric step so a BigInt element stays BigInt - // (`var x = [0n]; ++x[0] === 1n`). Mirrors the identifier Update + - // PropertyUpdate paths. #4918 prefix/postfix bigint. - let old_num = blk.call(DOUBLE, "js_to_numeric", &[(DOUBLE, &old)]); - let step_arg = match op { - BinaryOp::Sub => "0", - _ => "1", - }; - let new = blk.call( - DOUBLE, - "js_numeric_step", - &[(DOUBLE, &old_num), (I32, step_arg)], - ); - blk.call( - DOUBLE, - "js_dyn_index_set", - &[(DOUBLE, &obj_box), (DOUBLE, &idx_box), (DOUBLE, &new)], - ); - Ok(if *prefix { new } else { old_num }) - }) + // -------- obj.field++ / obj.field-- / a[i]++ (member updates) -------- + // Moved to `expr/member_update.rs` in #7628 to keep this file under + // the 2000-line cap; the arms are verbatim. + Expr::PropertyUpdate { .. } | Expr::IndexUpdate { .. } => { + super::member_update::lower(ctx, expr) } // -------- path.basename -------- diff --git a/crates/perry-codegen/src/expr/issue7628_rooting_tests.rs b/crates/perry-codegen/src/expr/issue7628_rooting_tests.rs new file mode 100644 index 0000000000..43e65ca2aa --- /dev/null +++ b/crates/perry-codegen/src/expr/issue7628_rooting_tests.rs @@ -0,0 +1,307 @@ +//! Rooting coverage for `a[i]++` / `o.f++` (#7628). +//! +//! # What the issue claimed, and what the A/B actually showed +//! +//! Both lowerings are a read-modify-write whose two operands are consumed by +//! **four** calls with collection points between them: +//! +//! ```text +//! old = js_dyn_index_get(obj, idx) ; a getter / Proxy trap +//! old_num = js_to_numeric(old) ; a valueOf +//! new = js_numeric_step(old_num, step) +//! js_dyn_index_set(obj, idx, new) ; a setter / Proxy trap +//! ``` +//! +//! #7628 filed the operand pair as a live #7154: `with_operands_rooted` +//! re-reads at exactly ONE point, so the registers `js_dyn_index_set` reads +//! were the ones produced above `js_dyn_index_get`. **On the emitted IR that is +//! not what happens, and the sabotage arm is how that was found rather than +//! argued.** Collapsing the per-use re-reads back to one — and, for +//! `PropertyUpdate`, dropping the receiver's root entirely — leaves the emitted +//! IR *unchanged in the relevant respect*: `root_reload` (#7280) rematerialises +//! the slot load at each use a collection point can reach, including through +//! the `ptrtoint` + `and POINTER_MASK` handle derivation: +//! +//! ```llvm +//! %r49.rs4p = load ptr addrspace(1), ptr %r29 ; inserted by root_reload +//! %r49 = ptrtoint ptr addrspace(1) %r49.rs4p to i64 +//! %r50 = and i64 %r49, 281474976710655 +//! call void @js_object_set_field_by_name(i64 %r50, i64 %r53, double %r46) +//! ``` +//! +//! So the operand half of #7628 is **not a live bug on the default build**, and +//! the per-use re-reads the source now emits are belt-and-braces: they cost +//! nothing (the pass would emit them anyway) and they stop the arm depending on +//! a pass that carries a documented side condition ("unless a store to that +//! slot can also run on the way") and a corpus allowlist. +//! +//! # What IS a live bug, and the test that discriminates it +//! +//! The RESULT. `js_to_numeric` / `js_numeric_step` hand back a heap +//! `BigIntHeader` for a BigInt element, and whichever of the two the expression +//! yields — `old_num` for postfix, `new` for prefix — is live across +//! `js_dyn_index_set`, i.e. across a user setter. It is a bare call result with +//! **no slot**, so `root_reload` has nothing to reload from; that is the +//! taxonomy's case (d) and the one this fix closes with +//! `RootedGroup::adopt_emitted`. +//! +//! [`the_result_is_rooted_only_when_the_element_may_be_a_bigint`] is the only +//! test here with a counterfactual, and it carries its own: the typed-array arm +//! (`is_provably_not_bigint` proves the element is `Number | undefined`) takes +//! `protect == false` and its returned register is produced ABOVE the write — +//! measured, not assumed. That is the same shape as the unrooted lowering, so +//! the two arms together show the root is what moves the read. +//! +//! # The remaining test is a PIPELINE assertion, and says so +//! +//! [`the_emitted_ir_rereads_both_operands_below_the_read`] holds because of +//! `root_reload`, not because of this file's source form, and it cannot fail on +//! a change to `expr/instance_misc1.rs` alone. It is kept anyway because it can +//! fail on a `root_reload` regression for this shape, which is a property +//! nothing else pins. It is NOT evidence about the lowering, and naming it +//! otherwise is how a green gate stops meaning anything. +//! +//! Slot counts are avoided throughout for the reason slice 8 recorded: under +//! statepoints `reserve_shadow_slot` returns a stack-map index and no +//! `js_shadow_frame_enter` is emitted at all, so a frame-width assertion reads +//! zero and passes on the default build. `temp_root_push_double` is likewise a +//! plain alloca `store` in alloca mode, so counting `js_gc_temp_root_push` reads +//! zero here too — the first version of the third test did exactly that and +//! compared 0 against 0. + +use perry_hir::types::Type; +use perry_hir::{BinaryOp, Expr, Function, Module as HirModule, Stmt}; + +use super::slice7_rooting_tests::require_call_line; +use super::slice8_rooting_tests::{call_operand_of, producer_line}; + +fn compile_body(name: &str, 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(), + return_type: Type::Any, + body, + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }); + let opts = crate::CompileOptions { + emit_ir_only: true, + ..Default::default() + }; + let bytes = crate::compile_module(&hir, opts).expect("test module compiles"); + String::from_utf8(bytes).expect("LLVM IR is UTF-8") +} + +/// Assert that operand `n` of `writer` is produced strictly below the call to +/// `reader` — i.e. it was re-read after the window rather than carried across +/// it. +fn assert_operand_reread_below(ir: &str, writer: &str, n: usize, reader: &str, what: &str) { + let window = require_call_line(ir, reader); + let reg = call_operand_of(ir, writer, n); + let produced = producer_line(ir, ®); + assert!( + produced > window, + "{what}: {writer} reads {reg} as operand {n}, produced at line {produced} — at or \ + ABOVE line {window}, where {reader} runs. {reader} can re-enter user code, so an \ + evacuating cycle inside it relocates the object and that register names from-space. \ + It has to be re-read below the call, not carried across it.\n{ir}" + ); +} + +/// `const o = { items: [1, 2] };` — the receiver is reached through a FIELD +/// read rather than a local, for the reason slice 8's header records: for a +/// local with a shadow slot the `ptr addrspace(1)` retype pass rematerialises +/// the load at the use site, so an unrooted lowering can read fresh *by +/// accident* and the test goes green against the bug. +fn with_object_local(tail: Stmt) -> Vec { + vec![ + Stmt::Let { + id: 0, + name: "o".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Object(vec![( + "items".to_string(), + Expr::Array(vec![Expr::Number(1.0), Expr::Number(2.0)]), + )])), + }, + tail, + ] +} + +fn field_of_o(property: &str) -> Expr { + Expr::PropertyGet { + object: Box::new(Expr::LocalGet(0)), + property: property.to_string(), + byte_offset: 0, + } +} + +fn items_of_o() -> Expr { + field_of_o("items") +} + +/// The register the function returns. +/// +/// Used instead of a slot count on purpose: `temp_root_push_double` lowers to a +/// `js_gc_temp_root_push` CALL only in the FFI-fallback mode. In alloca mode it +/// is a plain `store` into a pooled alloca and under statepoints +/// `reserve_shadow_slot` hands back a stack-map index — so counting the helper +/// name reads zero on the default build and passes vacuously, which is exactly +/// the trap slice 8's header records. +fn returned_register(ir: &str) -> String { + ir.lines() + .find(|l| l.trim_start().starts_with("ret double %")) + .unwrap_or_else(|| panic!("no `ret double %` in:\n{ir}")) + .trim() + .rsplit(' ') + .next() + .expect("ret has an operand") + .to_string() +} + +/// `o.items[o.n]++` — the emitted IR re-reads both operands below +/// `js_dyn_index_get`. +/// +/// ★ **A pipeline assertion, not a statement about this lowering.** It holds +/// via `root_reload` and stays green with the source-level re-reads collapsed +/// (measured — see the module header), so it cannot fail on a change to +/// `expr/instance_misc1.rs` alone. What it can catch is a `root_reload` +/// regression for this shape. +/// +/// The index is a field read rather than a literal because a literal is a +/// constant with no definition to order against (`Reuse`, correctly). +#[test] +fn the_emitted_ir_rereads_both_operands_below_the_read() { + let ir = compile_body( + "issue7628_index_update", + with_object_local(Stmt::Expr(Expr::IndexUpdate { + object: Box::new(items_of_o()), + index: Box::new(field_of_o("n")), + op: BinaryOp::Add, + prefix: false, + })), + ); + // The generic arm was reached — without this the rest is vacuous. + require_call_line(&ir, "js_dyn_index_get"); + assert_operand_reread_below( + &ir, + "js_dyn_index_set", + 0, + "js_dyn_index_get", + "the IndexUpdate receiver", + ); + assert_operand_reread_below( + &ir, + "js_dyn_index_set", + 1, + "js_dyn_index_get", + "the IndexUpdate index", + ); +} + +/// `o.count++` — the emitted IR re-derives the RAW `i64` receiver and key +/// handles below `js_object_get_field_by_name_f64`. +/// +/// ★ Also a pipeline assertion. #7280's taxonomy lists "a pointer already +/// unboxed to raw `i64`" as case (a), the class `root_reload` cannot repair — +/// but that is about a raw handle a helper RETURNS, not one masked out of a +/// NaN-boxed value the pass has spilled. Here the chain's root is a +/// `load ptr addrspace(1)`, and the pass rematerialises the whole +/// `load` → `ptrtoint` → `and` chain at the use. Verified by dropping the +/// receiver's root entirely: still green. +#[test] +fn the_emitted_ir_rederives_the_raw_handles_below_the_read() { + let ir = compile_body( + "issue7628_property_update", + with_object_local(Stmt::Expr(Expr::PropertyUpdate { + object: Box::new(items_of_o()), + property: "count".to_string(), + op: BinaryOp::Add, + prefix: false, + })), + ); + require_call_line(&ir, "js_object_get_field_by_name_f64"); + assert_operand_reread_below( + &ir, + "js_object_set_field_by_name", + 0, + "js_object_get_field_by_name_f64", + "the PropertyUpdate receiver handle", + ); + assert_operand_reread_below( + &ir, + "js_object_set_field_by_name", + 1, + "js_object_get_field_by_name_f64", + "the PropertyUpdate key handle", + ); +} + +/// The RESULT of `a[i]++` is live across `js_dyn_index_set`, which runs a user +/// setter — so for an element that may be a BigInt it must be re-read below +/// that call. +/// +/// And the zero-cost half, which is the same assertion inverted: a typed-array +/// element is `Number | undefined` by construction, so +/// `is_provably_not_bigint` proves neither `js_to_numeric`'s result nor +/// `js_numeric_step`'s can be a heap `BigIntHeader`, `protect` is `false`, and +/// the arm keeps the register it had. Without the second half a future "root +/// every result" widening would tax every update unnoticed. +#[test] +fn the_result_is_rooted_only_when_the_element_may_be_a_bigint() { + let unproven = compile_body( + "issue7628_result_root", + with_object_local(Stmt::Return(Some(Expr::IndexUpdate { + object: Box::new(items_of_o()), + index: Box::new(field_of_o("n")), + op: BinaryOp::Add, + prefix: false, + }))), + ); + let write = require_call_line(&unproven, "js_dyn_index_set"); + let produced = producer_line(&unproven, &returned_register(&unproven)); + assert!( + produced > write, + "a BigInt-capable element's postfix result is live across js_dyn_index_set (a user \ + setter) and must be re-read below it — produced at line {produced}, the write is at \ + line {write}.\n{unproven}" + ); + + let typed = compile_body( + "issue7628_result_no_root", + vec![ + Stmt::Let { + id: 0, + name: "ta".to_string(), + ty: Type::Named("Uint8Array".to_string()), + mutable: false, + init: Some(Expr::Uint8ArrayNew(Some(Box::new(Expr::Number(4.0))))), + }, + Stmt::Return(Some(Expr::IndexUpdate { + object: Box::new(Expr::LocalGet(0)), + index: Box::new(Expr::Number(0.0)), + op: BinaryOp::Add, + prefix: false, + })), + ], + ); + let typed_write = require_call_line(&typed, "js_dyn_index_set"); + let typed_produced = producer_line(&typed, &returned_register(&typed)); + assert!( + typed_produced < typed_write, + "a typed-array element can never be a BigInt, so the result must keep the register \ + js_to_numeric produced — no slot, no re-read. Produced at line {typed_produced}, the \ + write is at line {typed_write}; if this is BELOW the write the \ + `is_provably_not_bigint` gate has stopped gating.\n{typed}" + ); +} diff --git a/crates/perry-codegen/src/expr/member_update.rs b/crates/perry-codegen/src/expr/member_update.rs new file mode 100644 index 0000000000..9067d5252c --- /dev/null +++ b/crates/perry-codegen/src/expr/member_update.rs @@ -0,0 +1,363 @@ +//! `o.f++` / `o.f--` / `a[i]++` / `--a[i]` — the member read-modify-write arms. +//! +//! Split out of `expr/instance_misc1.rs` in #7628, which pushed that file past +//! the 2000-line cap. The arm bodies are verbatim; the dispatch entry is +//! `lower`, called from that file's `Expr::PropertyUpdate | Expr::IndexUpdate` +//! arm. +//! +//! # The rooting story, and the part of #7628 that did not survive measurement +//! +//! Both arms consume their operands at four calls with collection points +//! between them: +//! +//! ```text +//! old = (obj, key) ; a getter / Proxy trap +//! old_num = js_to_numeric(old) ; a valueOf +//! new = js_numeric_step(old_num, s) ; allocates only (#7198) +//! (obj, key, new) ; a setter / Proxy trap +//! ``` +//! +//! #7628 filed the group-wide single re-read as a live #7154 and asked for a +//! per-use-re-read combinator. Slice 6 had already built one (`RootedGroup`), +//! so both arms use it and no new primitive arrived with this caller — but the +//! operand half turned out **not to be a live bug**: `root_reload` (#7280) +//! rematerialises the slot load at every use a collection point can reach, +//! including through the `ptrtoint` + `and POINTER_MASK` handle derivation. +//! Collapsing the re-reads back to one leaves the emitted IR the same. The +//! per-use form is kept because it is free and removes the dependence on a pass +//! with a documented side condition; it is not the repair. +//! +//! The repair is the **result**. For a BigInt element `js_to_numeric` / +//! `js_numeric_step` hand back a heap `BigIntHeader`, and the one the +//! expression yields — `old_num` for postfix, `new` for prefix — is live across +//! the write, i.e. across a user setter, as a bare call result with **no slot** +//! for `root_reload` to reload from. `RootedGroup::adopt_emitted` closes it, +//! gated on `is_provably_not_bigint` so a typed-array `ta[i]++` pays nothing. +//! `expr/issue7628_rooting_tests.rs` carries the measurement and the sabotage +//! arms. + +use anyhow::Result; +use perry_hir::{BinaryOp, Expr}; + +use crate::nanbox::POINTER_MASK_I64; +use crate::rooting::{self, Repr}; +use crate::types::{DOUBLE, I32, I64, I8}; + +use super::{lower_expr, FnCtx}; + +pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { + match expr { + // -------- obj.field++ / obj.field-- (PropertyUpdate) -------- + // Lowered as: load → fadd/fsub 1.0 → store. Same as the + // Update variant but for a property instead of a local. + Expr::PropertyUpdate { + object, + property, + op, + prefix, + } => { + // Scalar replacement fast path: load → fadd/fsub 1.0 → store + // on the field's alloca, no heap traffic. + if let Expr::LocalGet(id) = object.as_ref() { + if let Some(slot) = ctx + .scalar_replaced + .get(id) + .and_then(|fs| fs.get(property.as_str())) + .cloned() + { + let blk = ctx.block(); + let old = blk.load(DOUBLE, &slot); + let old_num = blk.call(DOUBLE, "js_number_coerce", &[(DOUBLE, &old)]); + let new = match op { + BinaryOp::Sub => blk.fsub(&old_num, "1.0"), + _ => blk.fadd(&old_num, "1.0"), + }; + blk.store(DOUBLE, &new, &slot); + return Ok(if *prefix { new } else { old_num }); + } + } + if let Expr::This = object.as_ref() { + if let Some(slot) = ctx + .scalar_ctor_target + .last() + .and_then(|tid| ctx.scalar_replaced.get(tid)) + .and_then(|fs| fs.get(property.as_str())) + .cloned() + { + let blk = ctx.block(); + let old = blk.load(DOUBLE, &slot); + let old_num = blk.call(DOUBLE, "js_number_coerce", &[(DOUBLE, &old)]); + let new = match op { + BinaryOp::Sub => blk.fsub(&old_num, "1.0"), + _ => blk.fadd(&old_num, "1.0"), + }; + blk.store(DOUBLE, &new, &slot); + return Ok(if *prefix { new } else { old_num }); + } + } + // Representation-selection Phase 3b: `o.f++` on a shape-proven + // Ptr local whose field is numeric-proven — bare + // load/fadd/store at the fixed offset, no by-name runtime calls. + // The store keeps the raw-slot plain-finite discipline (an + // Inf-crossing update side-exits to the by-name setter, which + // performs the layout downgrade the GC scan relies on). + // (Phase 5a's proven `this` never claims numeric fields, so this + // site remains Phase-3b-local-only in practice.) + { + let fact = ctx.ptr_shape_receiver_fact(object.as_ref()).cloned(); + { + if let Some(fact) = fact { + if fact.numeric_fields.contains(property.as_str()) { + if let Some(field_index) = + crate::type_analysis::class_field_global_index( + ctx, + &fact.class_name, + property, + ) + { + ctx.note_ptr_shape_consumed(object.as_ref(), "ptr_shape_update"); + let recv_box = lower_expr(ctx, object)?; + let field_idx_str = field_index.to_string(); + let header_skip = crate::target_layout::object_header_size_bytes( + ctx.target_triple, + ) + .to_string(); + let (obj_handle, field_ptr, old, new) = { + 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 old = blk.load(DOUBLE, &field_ptr); + let new = match op { + BinaryOp::Sub => blk.fsub(&old, "1.0"), + _ => blk.fadd(&old, "1.0"), + }; + (obj_handle, field_ptr, old, new) + }; + let store_idx = ctx.new_block("ptr_shape_update.raw_store"); + let cold_idx = ctx.new_block("ptr_shape_update.downgrade"); + let merge_idx = ctx.new_block("ptr_shape_update.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 new_bits = blk.bitcast_double_to_i64(&new); + let finite = crate::expr::class_field_inline_guard:: + emit_plain_finite_number_check(blk, &new_bits); + blk.cond_br(&finite, &store_label, &cold_label); + } + ctx.current_block = store_idx; + { + // Reached only when the finite check above + // proved `new`'s exponent is NOT all-ones; + // every NaN-box tag (INT32/STRING/POINTER/ + // BIGINT) has an all-ones exponent. + let blk = ctx.block(); + // GC_STORE_AUDIT(POINTER_FREE): a genuine + // unboxed double by the proof above, never + // a GC pointer — no edge, so no barrier. + blk.store(DOUBLE, &new, &field_ptr); + blk.br(&merge_label); + } + ctx.current_block = cold_idx; + { + let key_idx = ctx.strings.intern(property); + let key_handle_global = + format!("@{}", ctx.strings.entry(key_idx).handle_global); + 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_handle = blk.and(I64, &key_bits, POINTER_MASK_I64); + blk.call_void( + "js_object_set_field_by_name", + &[(I64, &obj_handle), (I64, &key_handle), (DOUBLE, &new)], + ); + blk.br(&merge_label); + } + ctx.current_block = merge_idx; + return Ok(if *prefix { new } else { old }); + } + } + } + } + } + // #7628's scope note: the same read-modify-write skeleton as + // `Expr::IndexUpdate`, and the same repair — with one extra shape + // on top. `obj_handle` is a RAW `i64` derived from the receiver + // before `js_object_get_field_by_name_f64` (a getter) and + // `js_to_numeric` (a `valueOf`), and re-read from it afterwards by + // `js_object_set_field_by_name`. That is the #7280 taxonomy's case + // (a): a pointer already unboxed to raw `i64` cannot be repaired by + // re-reading a slot, so the fix is to root the BOXED receiver and + // re-derive the handle below the window, not to root the handle. + // + // `key_handle` is the same shape and needs no slot: it is derived + // from a `__perry_init_strings_*` handle global, which is a + // registered root the collector rewrites, and the literal is + // immutable — so re-loading it below the window is `Reload`, + // `operand_is_reloadable`'s exact argument, at two instructions. + let key_idx = ctx.strings.intern(property); + let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); + let field_read = Expr::PropertyGet { + object: object.clone(), + property: property.clone(), + byte_offset: 0, + }; + let result_may_be_heap = + !crate::type_analysis::is_provably_not_bigint(ctx, &field_read); + rooting::with_rooted_group(ctx, 1, |ctx, group| { + let obj = group.lower(ctx, object, true)?; + let derive_handles = |ctx: &mut FnCtx<'_>, obj_box: &str| { + let blk = ctx.block(); + let obj_bits = blk.bitcast_double_to_i64(obj_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_handle = blk.and(I64, &key_bits, POINTER_MASK_I64); + (obj_handle, key_handle) + }; + let old = { + let obj_box = group.reread(ctx, obj)?; + let (obj_handle, key_handle) = derive_handles(ctx, &obj_box); + ctx.block().call( + DOUBLE, + "js_object_get_field_by_name_f64", + &[(I64, &obj_handle), (I64, &key_handle)], + ) + }; + // ToNumeric + Type(old)::add/sub(old, unit): a BigInt field stays a + // BigInt (`var x = {y:0n}; ++x.y === 1n`), not the Number `1`. Mirrors + // the identifier `Expr::Update` path. #4918 prefix/postfix bigint. + let old_num = ctx.block().call(DOUBLE, "js_to_numeric", &[(DOUBLE, &old)]); + let old_num_root = + group.adopt_emitted(ctx, Repr::Boxed, &old_num, result_may_be_heap && !*prefix); + let step_arg = match op { + BinaryOp::Sub => "0", + _ => "1", + }; + let new = ctx.block().call( + DOUBLE, + "js_numeric_step", + &[(DOUBLE, &old_num), (I32, step_arg)], + ); + let new_root = + group.adopt_emitted(ctx, Repr::Boxed, &new, result_may_be_heap && *prefix); + { + let obj_box = group.reread(ctx, obj)?; + let (obj_handle, key_handle) = derive_handles(ctx, &obj_box); + let new_arg = group.reread_emitted(ctx, new_root); + ctx.block().call_void( + "js_object_set_field_by_name", + &[(I64, &obj_handle), (I64, &key_handle), (DOUBLE, &new_arg)], + ); + } + Ok(if *prefix { + group.reread_emitted(ctx, new_root) + } else { + group.reread_emitted(ctx, old_num_root) + }) + }) + } + + // -------- arr[idx]++ / arr[idx]-- / ++arr[idx] / --arr[idx] -------- + // + // Issue #957: lodash's `countBy` uses `++result[key]` which previously + // bailed `expression IndexUpdate not yet supported` and stubbed the + // entire module, leaving `import _ from "lodash"` resolving to + // undefined. Lower as a tag-aware read+modify+write through the + // `js_dyn_index_get` / `js_dyn_index_set` runtime helpers — they + // dispatch by gc_type at runtime, so the same emission works for + // arrays, plain objects, and TypedArrays without static type + // knowledge. `object` and `index` lower once into SSA registers so + // side effects are not re-evaluated. + Expr::IndexUpdate { + object, + index, + op, + prefix, + } => { + // #7628. The operand pair is consumed by FOUR calls with collection + // points between them: + // + // old = js_dyn_index_get(obj, idx) ; a getter / Proxy trap + // old_num = js_to_numeric(old) ; a valueOf + // new = js_numeric_step(old_num, s) ; allocates only (#7198) + // js_dyn_index_set(obj, idx, new) ; a setter / Proxy trap + // + // `with_operands_rooted` (#7615 slice 2) re-reads at exactly ONE + // point, at the end of the operand list. `RootedGroup` (slice 6) + // re-reads at any number of caller-chosen points, which is this + // shape, so the operand pair is now re-read per use and no new + // combinator arrives with this caller. + // + // ★ That half is belt-and-braces, NOT the repair — see the module + // header. `root_reload` already rematerialises these slot loads at + // each use; collapsing them back to one leaves the emitted IR the + // same, measured. + // + // The repair is the RESULT, and it is not in the issue. For a + // BigInt element `js_to_numeric` / `js_numeric_step` hand back a + // heap `BigIntHeader`, and whichever of the two the expression + // yields — `old_num` for postfix, `new` for prefix — is live across + // `js_dyn_index_set`, i.e. across a user setter, as a bare call + // result with no slot for `root_reload` to reload from. `protect` + // is the element's own non-BigInt proof, so a typed-array `ta[i]++` + // keeps the IR it had. + let element_read = Expr::IndexGet { + object: object.clone(), + index: index.clone(), + }; + let result_may_be_heap = + !crate::type_analysis::is_provably_not_bigint(ctx, &element_read); + rooting::with_rooted_group(ctx, 2, |ctx, group| { + let obj = group.lower(ctx, object, true)?; + let idx = group.lower(ctx, index, true)?; + let old = { + let (obj_box, idx_box) = (group.reread(ctx, obj)?, group.reread(ctx, idx)?); + ctx.block().call( + DOUBLE, + "js_dyn_index_get", + &[(DOUBLE, &obj_box), (DOUBLE, &idx_box)], + ) + }; + // ToNumeric + numeric step so a BigInt element stays BigInt + // (`var x = [0n]; ++x[0] === 1n`). Mirrors the identifier Update + + // PropertyUpdate paths. #4918 prefix/postfix bigint. + let old_num = ctx.block().call(DOUBLE, "js_to_numeric", &[(DOUBLE, &old)]); + let old_num_root = + group.adopt_emitted(ctx, Repr::Boxed, &old_num, result_may_be_heap && !*prefix); + let step_arg = match op { + BinaryOp::Sub => "0", + _ => "1", + }; + let new = ctx.block().call( + DOUBLE, + "js_numeric_step", + &[(DOUBLE, &old_num), (I32, step_arg)], + ); + let new_root = + group.adopt_emitted(ctx, Repr::Boxed, &new, result_may_be_heap && *prefix); + { + let (obj_box, idx_box) = (group.reread(ctx, obj)?, group.reread(ctx, idx)?); + let new_arg = group.reread_emitted(ctx, new_root); + ctx.block().call( + DOUBLE, + "js_dyn_index_set", + &[(DOUBLE, &obj_box), (DOUBLE, &idx_box), (DOUBLE, &new_arg)], + ); + } + Ok(if *prefix { + group.reread_emitted(ctx, new_root) + } else { + group.reread_emitted(ctx, old_num_root) + }) + }) + } + + _ => unreachable!("expr/instance_misc1.rs dispatched a non-update variant here"), + } +} diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 4a64446e5f..8baf72c996 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -141,6 +141,8 @@ pub(crate) mod shadow_inline; // and it now lives outside `crate::expr`. #[cfg(test)] mod call_spread_rooting_tests; +#[cfg(test)] +mod issue7628_rooting_tests; pub(crate) mod shadow_slot; #[cfg(test)] mod slice7_rooting_tests; @@ -1898,6 +1900,7 @@ mod computed_store_rooting_tests; mod index_set; mod index_set_typed_array; mod instance_misc1; +mod member_update; pub(crate) use instance_misc1::builtin_parent_reserved_class_id; pub(crate) mod class_field_inline_guard; pub(crate) mod element_shape_guard; diff --git a/crates/perry-codegen/src/expr/slice8_rooting_tests.rs b/crates/perry-codegen/src/expr/slice8_rooting_tests.rs index 4b4dee8f0a..3d7fd86ccc 100644 --- a/crates/perry-codegen/src/expr/slice8_rooting_tests.rs +++ b/crates/perry-codegen/src/expr/slice8_rooting_tests.rs @@ -78,7 +78,7 @@ const PURE_OPS: &[&str] = &[ /// red) came back GREEN on all four. "The unbox sits below its own window" is /// exactly the shape a one-level check cannot see, which is #7280 taxonomy (c) /// stated as a property of the instrument instead of the bug. -fn producer_line(ir: &str, reg: &str) -> usize { +pub(super) fn producer_line(ir: &str, reg: &str) -> usize { let lines: Vec<&str> = ir.lines().collect(); let mut current = reg.to_string(); for _ in 0..32 { @@ -109,7 +109,7 @@ fn producer_line(ir: &str, reg: &str) -> usize { } /// Line index of a call to `callee` that is not a `declare`. -fn call_line_of(ir: &str, callee: &str) -> usize { +pub(super) fn call_line_of(ir: &str, callee: &str) -> usize { let needle = format!("@{callee}("); ir.lines() .position(|l| l.contains(&needle) && !l.trim_start().starts_with("declare")) @@ -117,7 +117,7 @@ fn call_line_of(ir: &str, callee: &str) -> usize { } /// The `n`-th SSA operand of the call to `callee`. -fn call_operand_of(ir: &str, callee: &str, n: usize) -> String { +pub(super) fn call_operand_of(ir: &str, callee: &str, n: usize) -> String { let idx = call_line_of(ir, callee); let line = ir.lines().nth(idx).expect("index came from this IR"); let args = line diff --git a/test-files/test_gap_7628_index_update_rooted.ts b/test-files/test_gap_7628_index_update_rooted.ts new file mode 100644 index 0000000000..c96f95521e --- /dev/null +++ b/test-files/test_gap_7628_index_update_rooted.ts @@ -0,0 +1,62 @@ +// #7628: `a[i]++` is a read-modify-write over two operands consumed by four +// calls, each of which can re-enter user code (a getter, a `valueOf`, a +// setter). The operands and the result must be re-read below every one of them. +// Behaviour is unchanged by the rooting repair; this pins the semantics the +// repair must not perturb, on every shape that reaches the generic lowering. + +// --- plain array, both fixities -------------------------------------------- +const a: number[] = [10, 20, 30]; +let i = 0; +console.log(a[i]++, a[0]); +console.log(++a[i], a[0]); +console.log(a[i]--, a[0]); +console.log(--a[i], a[0]); + +// --- BigInt elements stay BigInt (#4918) ----------------------------------- +const bs: bigint[] = [0n, 5n]; +console.log(bs[0]++, bs[0]); +console.log(++bs[1], bs[1]); +console.log(typeof bs[0]); + +// --- accessor receiver: the `valueOf` between the read and the write -------- +class Cell { + n: number; + constructor(n: number) { + this.n = n; + } + valueOf(): number { + return this.n; + } +} +const cells: Record = { k: new Cell(7) }; +console.log(cells["k" as string]++); +console.log(cells["k"]); + +// --- object keys, the lodash `countBy` shape (#957) ------------------------ +const counts: Record = {}; +function key(n: number): string { + return n % 2 === 0 ? "even" : "odd"; +} +for (let n = 0; n < 6; n++) { + const k = key(n); + counts[k] = (counts[k] ?? 0) + 0; + counts[k]++; +} +console.log(JSON.stringify(counts)); + +// --- property update, both fixities ---------------------------------------- +const o: { c: number; b: bigint } = { c: 1, b: 1n }; +console.log(o.c++, o.c); +console.log(++o.c, o.c); +console.log(o.b++, o.b); +console.log(++o.b, o.b); + +// --- the index expression is evaluated once -------------------------------- +let calls = 0; +function idx(): number { + calls++; + return 1; +} +const once: number[] = [0, 100]; +once[idx()]++; +console.log(once[1], calls); From f6caff0fa0ed10414693fe79558c013279663b73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 12:44:10 +0200 Subject: [PATCH 3/7] fix(codegen): pointer_locals stops typing every Uint8ArrayGet as a Number (#6998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `const it = u8[Symbol.iterator]` lowers to `Uint8ArrayGet { index: SymbolFor }` — verified on the emitted HIR, which is the reachability the issue left open — and the collector typed it Number unconditionally, so the local got no shadow slot and the value was invisible to the collector. The arm now answers Number only for a structurally numeric key (the same `index_is_definitely_numeric` proof the IndexGet typed-array arm uses) and None otherwise. Structural on purpose: a `number`-declared index local is not evidence, and the sharper `expr_is_known_non_pointer_shadow_value` test needs an FnCtx this collector runs before. The byte-read arm #6996 paid to keep free is unchanged and pinned. Note in the changelog: the runtime consequence is currently masked by a second, behavioural defect — five other collectors make the same unconditional assumption and force the i32 lowering, so `u8[]` reads a byte instead of the property. Filed separately; this is a latent-soundness fix that becomes load-bearing when that is repaired. --- ...2-uint8array-get-pointer-classification.md | 49 ++++++++ .../src/collectors/pointer_locals.rs | 108 +++++++++++++++++- 2 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 changelog.d/7692-uint8array-get-pointer-classification.md diff --git a/changelog.d/7692-uint8array-get-pointer-classification.md b/changelog.d/7692-uint8array-get-pointer-classification.md new file mode 100644 index 0000000000..185af374e3 --- /dev/null +++ b/changelog.d/7692-uint8array-get-pointer-classification.md @@ -0,0 +1,49 @@ +### `pointer_locals` stops typing every `Uint8ArrayGet` as a Number (#6998) + +`collect_pointer_typed_locals`'s `expr_value_type` classified +`Expr::Uint8ArrayGet` as `Type::Number` with no regard for the key kind, so +`const it = u8[Symbol.iterator]` bound a value the collector had proven +non-pointer — and a non-pointer local gets **no shadow slot**, i.e. it is not a +GC root. + +**Reachability, which the issue explicitly left open, is now established on the +emitted HIR rather than argued.** `lower/expr_member/member_tail.rs` folds every +non-STRING key on a `Uint8Array`/`Buffer`-typed local onto this node, and a +symbol key is not a string, so `--print-hir` gives exactly: + +``` +Let { id: 1, name: "it", ty: Any, mutable: false, + init: Some(Uint8ArrayGet { array: LocalGet(0), + index: SymbolFor(String("@@__perry_wk_iterator")) }) } +``` + +The arm now answers `Some(Type::Number)` only for a **structurally** numeric key +— the same `index_is_definitely_numeric` proof the `IndexGet` typed-array arm +next to it already uses — and `None` otherwise. Structural on purpose: a +`number`-declared index local is not evidence, because Perry does not enforce +annotations, and `expr_is_known_non_pointer_shadow_value`'s sharper three-part +test needs an `FnCtx` this collector runs before. `None` is the conservative +direction: the local keeps a slot the collector rewrites harmlessly. The +byte-read arm — the one #6996 paid to keep free — is unchanged, and +`a_numeric_keyed_uint8array_read_still_pays_no_slot` pins that. + +**Found while verifying it: the runtime consequence is currently masked by a +second, behavioural defect, and that is worth more than this fix.** Five other +collectors (`integer_locals`, `i32_locals`, `int_valued_ta_locals`, +`not_bigint_locals`) and `type_analysis/numeric.rs` make the *same* +unconditional "a `Uint8ArrayGet` is a number" assumption, and they force the +i32-context lowering, so a non-numeric key on a typed-array-typed local reads a +**byte** instead of the property: + +| | node 26.5.1 | perry | +|---|---|---| +| `typeof u8[Symbol.iterator]` | `function` | `number` | +| `const k: any = "byteLength"; u8[k]` | `4` | `0` | +| `const k: any = "subarray"; typeof u8[k]` | `function` | `number` | +| `u8.tag = {…}; const k: any = "tag"; u8[k]` | `{"kind":"buffer"}` | `0` | + +So no heap value reaches such a local **today**, which is why this classification +bug has never bitten: it is a latent-soundness fix that becomes load-bearing the +moment the behavioural one is repaired. Filed separately rather than folded in — +the numeric-context collectors decide the `buf[i]` i32 fast path, which is the +hottest buffer code in the compiler, and changing them is a measured change. diff --git a/crates/perry-codegen/src/collectors/pointer_locals.rs b/crates/perry-codegen/src/collectors/pointer_locals.rs index e416934e0c..ed797babdc 100644 --- a/crates/perry-codegen/src/collectors/pointer_locals.rs +++ b/crates/perry-codegen/src/collectors/pointer_locals.rs @@ -237,10 +237,36 @@ pub fn collect_pointer_typed_locals( Expr::Undefined => Some(Type::Void), Expr::Null => Some(Type::Null), Expr::Bool(_) | Expr::Compare { .. } => Some(Type::Boolean), + // #6998: `Uint8ArrayGet` is NOT unconditionally numeric, and it is + // reachable — `const it = u8[Symbol.iterator]` lowers to + // `Uint8ArrayGet { array: LocalGet(u8), index: SymbolFor(…) }` + // (`lower/expr_member/member_tail.rs` folds every non-STRING key on + // a `Uint8Array`/`Buffer`-typed local onto this node, and a symbol + // key is not a string). Three of its lowerings hand back a heap + // value: a symbol key goes to `js_object_get_symbol_property`, an + // unproven key in JS-value context to + // `js_typed_array_index_get_dynamic`, and a non-numeric key in i32 + // context to `js_object_get_index_polymorphic` — the last two fall + // through to string-keyed property lookup, and an expando holds + // anything. Typed `Number` here, such a local is classified + // non-pointer, gets NO shadow slot, and the value is live in the + // program and invisible to the collector (#6951's class). + // + // The proof is the same STRUCTURAL one the `IndexGet` typed-array + // arm below already uses, and it is structural on purpose: a + // `number`-declared index local is not evidence, because Perry does + // not enforce annotations (CLAUDE.md, *Known Limitations*), and + // `expr_is_known_non_pointer_shadow_value`'s sharper test needs an + // `FnCtx` this collector runs before. Answering `None` for an + // unproven key is the conservative direction: the local keeps a + // slot the collector rewrites harmlessly. + Expr::Uint8ArrayGet { index, .. } if index_is_definitely_numeric(index) => { + Some(Type::Number) + } + Expr::Uint8ArrayGet { .. } => None, Expr::Number(_) | Expr::Integer(_) | Expr::Uint8ArrayLength(_) - | Expr::Uint8ArrayGet { .. } | Expr::BufferLength(_) | Expr::BufferIndexGet { .. } | Expr::MathFloor(_) @@ -1067,6 +1093,86 @@ mod tests { use super::*; use perry_hir::{Function, Param, Stmt}; + /// #6998: `const it = u8[Symbol.iterator]` binds a **heap** value — + /// `js_object_get_symbol_property` hands back the accessor — into a local + /// whose HIR type is `Any`. Typed `Number` here it would get no shadow + /// slot, so the value would be live in the program and invisible to the + /// collector. + /// + /// Reachability was established on the emitted HIR, not argued: the snippet + /// above lowers to + /// `Let { ty: Any, init: Uint8ArrayGet { array: LocalGet(0), index: SymbolFor(…) } }`. + #[test] + fn a_symbol_keyed_uint8array_read_keeps_its_shadow_slot() { + let stmts = vec![ + Stmt::Let { + id: 0, + name: "u8".to_string(), + ty: Type::Named("Uint8Array".to_string()), + mutable: false, + init: Some(Expr::Uint8ArrayNew(Some(Box::new(Expr::Integer(4))))), + }, + Stmt::Let { + id: 1, + name: "it".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Uint8ArrayGet { + array: Box::new(Expr::LocalGet(0)), + index: Box::new(Expr::SymbolFor(Box::new(Expr::String( + "@@__perry_wk_iterator".to_string(), + )))), + }), + }, + Stmt::Return(Some(Expr::LocalGet(1))), + ]; + let slots = collect_pointer_typed_locals(&[], &stmts, &HashSet::new()); + assert!( + slots.contains_key(&1), + "a symbol-keyed Uint8Array read is a heap value and must keep a shadow slot; \ + got slots for {:?}", + slots.keys().collect::>() + ); + } + + /// The other side, and it is what stops the fix being "give every element + /// read a slot": a STRUCTURALLY numeric key can only reach the byte + /// accessor, so the local stays non-pointer and keeps no slot. + #[test] + fn a_numeric_keyed_uint8array_read_still_pays_no_slot() { + let stmts = vec![ + Stmt::Let { + id: 0, + name: "u8".to_string(), + ty: Type::Named("Uint8Array".to_string()), + mutable: false, + init: Some(Expr::Uint8ArrayNew(Some(Box::new(Expr::Integer(4))))), + }, + Stmt::Let { + id: 1, + name: "b".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Uint8ArrayGet { + array: Box::new(Expr::LocalGet(0)), + index: Box::new(Expr::Binary { + op: BinaryOp::BitAnd, + left: Box::new(Expr::Integer(7)), + right: Box::new(Expr::Integer(3)), + }), + }), + }, + Stmt::Return(Some(Expr::LocalGet(1))), + ]; + let slots = collect_pointer_typed_locals(&[], &stmts, &HashSet::new()); + assert!( + !slots.contains_key(&1), + "a structurally numeric key reads a byte — a slot there is the #6996 cost with \ + nothing to protect; got slots for {:?}", + slots.keys().collect::>() + ); + } + fn return_array_of_type(depth: usize, leaf: Type) -> Type { (0..depth).fold(leaf, |ty, _| Type::Array(Box::new(ty))) } From f9b4ce7dc83769bdaf8dab51f65a28a3f3f7d6ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 12:56:49 +0200 Subject: [PATCH 4/7] fix(codegen): root new's non-class ctor args and the computed-key windows (#6986, #7640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #6986: three branches of lower_new_impl_inner early-return before the main class loop adopts into the enclosing RootedGroup, so the scope was open around them and empty — new Readline(...), new (...) and new Function(...) held argument registers across each other's lowering. They now adopt as each operand is produced (interleaved, never appended — a rooted finished list publishes an already-dangling argument 0) and re-read at the call. #7640 section D: unbox_str_handle calls js_get_string_pointer_unified, which materialises an SSO value into a fresh heap StringHeader. Six sites took the receiver's raw untagged pointer above it — #7280 taxonomy (a), which the rooting API structurally cannot express. Four are pure statement swaps at zero cost; two are cross-block and re-derive the handle in the string sub-block. #7640 section B: seven arms of index_get.rs lowered a receiver, then an unconstrained index (o[f()]), then used the receiver, with no rooting decision at all. Each is now one with_operands_rooted group over [object, index]; where the index provably cannot collect, operand_protection answers Reuse and the group emits nothing. Also fixes a migration-ledger violation the array_push tests introduced, and drops a js_gc_temp_root_push count that reads zero on the default build (temp roots lower to a plain alloca store in alloca mode) for the discriminating spec-order-block assertion. Still open on #7640: section A's typed-array stores, section C's statepoint claim, section E's callees. Still open on #6986: builtin.rs's ~22 arms, which need rooting threaded through lower_builtin_new's signature. --- .../7693-new-and-computed-key-windows.md | 71 +++++++ crates/perry-codegen/src/expr/array_push.rs | 34 ++- crates/perry-codegen/src/expr/index_get.rs | 195 +++++++++++------- crates/perry-codegen/src/expr/index_set.rs | 30 ++- crates/perry-codegen/src/lower_call/new.rs | 91 ++++++-- .../test_gap_7640_computed_key_windows.ts | 71 +++++++ 6 files changed, 381 insertions(+), 111 deletions(-) create mode 100644 changelog.d/7693-new-and-computed-key-windows.md create mode 100644 test-files/test_gap_7640_computed_key_windows.ts diff --git a/changelog.d/7693-new-and-computed-key-windows.md b/changelog.d/7693-new-and-computed-key-windows.md new file mode 100644 index 0000000000..049e84bc16 --- /dev/null +++ b/changelog.d/7693-new-and-computed-key-windows.md @@ -0,0 +1,71 @@ +### Constructor arguments on `lower_new`'s non-class branches, and the computed-key windows (#6986, #7640) + +#### `lower_new`'s non-class branches (#6986) + +`lower_new_impl` has wrapped its arguments in a rooted scope since #6983, but +three branches early-`return` before the main class loop ever adopts into it, so +the scope was open around them and empty: + +* **`new Readline(…)`** — `output` sat in a bare SSA register across `options`' + lowering *and* across every trailing argument's, before + `js_readline_promises_readline_new` read it; +* **`new (…)`** — `func_double` across every argument, and argument + `i` across the arguments after it, into `js_new_function_construct`; +* **`new Function(…)`** with a dynamic body — the same loop, into + `js_function_ctor_from_strings`. + +All three now adopt into the enclosing `RootedGroup` **as each operand is +produced** and re-read at the call. Interleaved, not appended: rooting a +finished list publishes an already-dangling argument 0 to the scanner, which +turns a silent wrong answer into a SIGSEGV (#6969's trap, restated at the new +helper). The `undefined` fillers for absent readline arguments stay literals, so +`new Readline()` still costs no slot. + +`lower_js_args_array` is no rescue and is not touched: it is a plain +`alloca_entry_array` pack with no `js_shadow_slot_bind`, so it copies whatever +bits it is handed, stale or not. The repair has to happen before it runs. + +**Not closed here:** `lower_call/builtin.rs`'s multi-argument constructors — +about 22 arms (`Uint8Array`/typed-array views, `DataView`, `RegExp`, `Event`, +`CustomEvent`, `DOMException`, `Console`, `SuppressedError`, `AsyncResource`, +`Blob`, `File`, the `node:sqlite` trio, `CronJob`, `Response`, `Request`, the +stream constructors). `lower_builtin_new` takes no rooting context at all, so +they cannot be reached from a `new.rs`-level fix; each needs its own +`with_operands_rooted`. Left on #6986 with the inventory. + +#### The computed-key windows (#7640) + +**Section D — the free one, all six sites.** `unbox_str_handle` is not a mask: it +calls `js_get_string_pointer_unified`, which materialises an SSO value into a +fresh heap `StringHeader`, i.e. one allocation per SSO unbox. Six sites computed +the receiver's raw untagged pointer *first* and called it *second*, so a raw +`i64` no root can name crossed a potential collection point — #7280 taxonomy (a), +which `crate::rooting` structurally cannot express. Four are pure statement +swaps at zero runtime cost (the same two instructions, in the other order): +`index_get.rs`'s dynamic-string-key arm, `index_set.rs`'s `globalThis[k] = v` +arm, its `arr[stringKey]` arm and its dynamic-string-key arm. Two are +cross-block — the handle was computed in the arm's entry block and used two +conditional branches later, inside the string sub-block — and are repaired by +re-deriving the handle in that block, below the key unbox. + +**Section B — the receiver across the key.** Seven arms of `index_get.rs` lowered +a receiver, then lowered an *unconstrained* index (`o[f()]`), then used the +receiver, with no rooting decision at all: the string receiver `s[f()]` (a heap +string, very much movable), the `recv_unknown` inline dyn-typed-array get, the +`is_array_expr && !is_numeric_expr` arm, `numeric_index_needs_runtime_key`, the +number-context `lower_unknown_local_index_get_for_number_context` tail, and both +`Expr::SymbolFor` arms (`js_symbol_for` interns, so it allocates). Each is now +one `rooting::with_operands_rooted` group over `[object, index]`, which is what +the file's two already-guarded arms are. Where the index provably cannot collect +— a literal, a plain local — `operand_protection` answers `Reuse` and the group +emits nothing, so the proven-index fast paths are untouched. + +`test-files/test_gap_7640_computed_key_windows.ts` covers all of it — the +side-effecting index on a string receiver, non-numeric and unproven-numeric +computed keys on an array, a symbol key, SSO string keys read and written, an +`a[stringKey]` write, `globalThis[k] = v`, and the byte-read fast path — and +matches node 26.5.1 byte for byte. + +**Still open on #7640:** section A's typed-array store arms and the bounded-index +array store, section C (the unsubstantiated statepoint claim above the +class-field store), and section E's callees. diff --git a/crates/perry-codegen/src/expr/array_push.rs b/crates/perry-codegen/src/expr/array_push.rs index 78778818f9..46594d62c9 100644 --- a/crates/perry-codegen/src/expr/array_push.rs +++ b/crates/perry-codegen/src/expr/array_push.rs @@ -1017,20 +1017,21 @@ mod receiver_order_tests { String::from_utf8(bytes).expect("LLVM IR is UTF-8") } - /// Call sites only — the module's `declare` line names the symbol too, and - /// counting it would compare 1 against 0 forever. - fn temp_root_pushes(ir: &str) -> usize { - ir.matches("call i32 @js_gc_temp_root_push").count() - } - /// An allocating argument that CANNOT reach the receiver's binding leaves - /// the historical argument-then-receiver order in place: no rooting IR, no - /// spec-order blocks, and therefore none of the cost #7634 worried about. + /// the historical argument-then-receiver order in place: no spec-order + /// blocks, and therefore none of the cost #7634 worried about. + /// + /// It asserts the ABSENCE of the spec-ordered arm rather than counting + /// `js_gc_temp_root_push` call sites, and that is not a stylistic choice: + /// `temp_root_push_double` lowers to a plain alloca `store` in alloca mode + /// and to a stack-map index under statepoints, so a count reads zero on the + /// default build and passes vacuously. The spec-ordered arm is the only + /// thing in this lowering that roots the receiver, so its absence is the + /// no-cost claim, stated in something the emitted IR always shows. /// - /// The assertion is one-sided on purpose. It cannot pass vacuously: the - /// same IR is required to still contain the push's own inline tier - /// (`apush.nofwd`), so an empty or failed compile fails here rather than - /// reporting "no roots found". + /// It cannot pass vacuously in the other direction either: the same IR is + /// required to still contain the push's own inline tier (`apush.nofwd`), so + /// an empty or failed compile fails here rather than reporting "clean". #[test] fn an_unreachable_binding_keeps_the_historical_order_and_roots_nothing() { let ir = push_ir(Expr::Object(vec![("v".to_string(), Expr::Number(1.0))])); @@ -1040,12 +1041,9 @@ mod receiver_order_tests { ); assert!( !ir.contains("apush.spec."), - "a plain local nothing else can reach must NOT take the spec-ordered arm:\n{ir}" - ); - assert_eq!( - temp_root_pushes(&ir), - 0, - "the hot push shape must gain no temp root:\n{ir}" + "a plain local nothing else can reach must NOT take the spec-ordered arm — and \ + the spec-ordered arm is the ONLY thing that roots the receiver, so its absence \ + is the no-cost claim:\n{ir}" ); } diff --git a/crates/perry-codegen/src/expr/index_get.rs b/crates/perry-codegen/src/expr/index_get.rs index 70c5da4a72..ea55a51fd5 100644 --- a/crates/perry-codegen/src/expr/index_get.rs +++ b/crates/perry-codegen/src/expr/index_get.rs @@ -536,11 +536,13 @@ pub(crate) fn lower_unknown_local_index_get_for_number_context( if index_is_static_string_or_symbol { return Ok(None); } - let obj_box = lower_expr(ctx, object)?; - let idx_d = lower_expr(ctx, index)?; - Ok(Some(lower_inline_dyn_typed_array_get( - ctx, &obj_box, &idx_d, true, - ))) + // #7640 section B: receiver live across an unconstrained index. + rooting::with_operands_rooted(ctx, &[object, index], |ctx, vals| { + let (obj_box, idx_d) = (vals[0].clone(), vals[1].clone()); + Ok(Some(lower_inline_dyn_typed_array_get( + ctx, &obj_box, &idx_d, true, + ))) + }) } fn lower_bounded_array_index_get( @@ -770,13 +772,18 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // path), which exposes `@@toStringTag` (`safe-stable-stringify`) // and `@@iterator`. if matches!(index.as_ref(), Expr::SymbolFor(_)) { - let obj_box = lower_expr(ctx, object)?; - let key_box = lower_expr(ctx, index)?; - return Ok(ctx.block().call( - DOUBLE, - "js_object_get_symbol_property", - &[(DOUBLE, &obj_box), (DOUBLE, &key_box)], - )); + // #7640 section B (MEDIUM): `Expr::SymbolFor` lowers to a + // real `js_symbol_for` call, which INTERNS — it allocates a + // SymbolHeader on first use — so the receiver was live + // across an allocation. + return rooting::with_operands_rooted(ctx, &[object, index], |ctx, vals| { + let (obj_box, key_box) = (vals[0].clone(), vals[1].clone()); + Ok(ctx.block().call( + DOUBLE, + "js_object_get_symbol_property", + &[(DOUBLE, &obj_box), (DOUBLE, &key_box)], + )) + }); } // #2063 / fractional numeric keys: only proven integer element // indices may take an i32 helper path. Try native @@ -988,26 +995,32 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // array path so `str[0]` doesn't fall through to a raw // double load. if is_string_expr(ctx, object) { - let s_box = lower_expr(ctx, object)?; - let idx_d = lower_expr(ctx, index)?; - let blk = ctx.block(); - // #3987: route through the canonical-index runtime helper (it - // takes the raw NaN-boxed key, not an `fptosi`'d i32) so a valid - // array index returns its char and every non-canonical key - // (`NaN`, `1.5`, negatives, OOB, `"01"`, non-numeric strings) - // returns `undefined` — matching ECMAScript / Node — instead of - // truncating the index and returning `""` for OOB. - // Pass the receiver STILL BOXED. Unboxing here masked off the - // low 48 bits, which is only a pointer for a heap STRING_TAG - // value — an inline SHORT_STRING_TAG (SSO) value's payload is - // the characters themselves, so the mask produced a garbage - // pointer and `(a + b)[0]` segfaulted on any short - // concatenation. The boxed entry point decides by tag. - return Ok(blk.call( - DOUBLE, - "js_string_index_get_boxed", - &[(DOUBLE, &s_box), (DOUBLE, &idx_d)], - )); + // #7640 section B: the receiver is a HEAP STRING and the index + // is unconstrained here — `s[f()]` lowers arbitrary user code + // between the two. The group states the decision; when the + // index provably cannot collect (a literal, a plain local) + // `operand_protection` answers `Reuse` and this emits nothing. + return rooting::with_operands_rooted(ctx, &[object, index], |ctx, vals| { + let (s_box, idx_d) = (vals[0].clone(), vals[1].clone()); + let blk = ctx.block(); + // #3987: route through the canonical-index runtime helper (it + // takes the raw NaN-boxed key, not an `fptosi`'d i32) so a valid + // array index returns its char and every non-canonical key + // (`NaN`, `1.5`, negatives, OOB, `"01"`, non-numeric strings) + // returns `undefined` — matching ECMAScript / Node — instead of + // truncating the index and returning `""` for OOB. + // Pass the receiver STILL BOXED. Unboxing here masked off the + // low 48 bits, which is only a pointer for a heap STRING_TAG + // value — an inline SHORT_STRING_TAG (SSO) value's payload is + // the characters themselves, so the mask produced a garbage + // pointer and `(a + b)[0]` segfaulted on any short + // concatenation. The boxed entry point decides by tag. + Ok(blk.call( + DOUBLE, + "js_string_index_get_boxed", + &[(DOUBLE, &s_box), (DOUBLE, &idx_d)], + )) + }); } // #6750 follow-up: a masked-window fact (dense range-loop or // straight-line region fast copy) covering this access means the @@ -1054,16 +1067,18 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { 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)?; - // #5525 follow-up: guarded inline typed-array element load at the - // access site (cache probe + bounds check + direct slot load), - // falling back to `js_dyn_index_get` on any guard miss. Removes - // the per-element out-of-line call + `lookup_typed_array_kind` + - // `js_number_coerce` on bcrypt's hot Int32Array `S[i]`/`P[i]`. - return Ok(lower_inline_dyn_typed_array_get( - ctx, &obj_box, &idx_d, false, - )); + // #7640 section B: receiver live across an unconstrained index. + return rooting::with_operands_rooted(ctx, &[object, index], |ctx, vals| { + let (obj_box, idx_d) = (vals[0].clone(), vals[1].clone()); + // #5525 follow-up: guarded inline typed-array element load at the + // access site (cache probe + bounds check + direct slot load), + // falling back to `js_dyn_index_get` on any guard miss. Removes + // the per-element out-of-line call + `lookup_typed_array_kind` + + // `js_number_coerce` on bcrypt's hot Int32Array `S[i]`/`P[i]`. + Ok(lower_inline_dyn_typed_array_get( + ctx, &obj_box, &idx_d, false, + )) + }); } // Three cases: // 1. Receiver is a known array → inline f64 element load @@ -1078,39 +1093,51 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // keys to the symbol-property resolver, which exposes the array // iterator for `Symbol.iterator`. if matches!(index.as_ref(), Expr::SymbolFor(_)) { - let obj_box = lower_expr(ctx, object)?; - let key_box = lower_expr(ctx, index)?; - return Ok(ctx.block().call( - DOUBLE, - "js_object_get_symbol_property", - &[(DOUBLE, &obj_box), (DOUBLE, &key_box)], - )); + // #7640 section B (MEDIUM): `Expr::SymbolFor` lowers to a + // real `js_symbol_for` call, which INTERNS — it allocates a + // SymbolHeader on first use — so the receiver was live + // across an allocation. + return rooting::with_operands_rooted(ctx, &[object, index], |ctx, vals| { + let (obj_box, key_box) = (vals[0].clone(), vals[1].clone()); + Ok(ctx.block().call( + DOUBLE, + "js_object_get_symbol_property", + &[(DOUBLE, &obj_box), (DOUBLE, &key_box)], + )) + }); } if !is_numeric_expr(ctx, index) { - let arr_box = lower_expr(ctx, object)?; - let idx_double = lower_expr(ctx, index)?; - let arr_handle = { - let blk = ctx.block(); - unbox_to_i64(blk, &arr_box) - }; - return Ok(ctx.block().call( - DOUBLE, - "js_array_get_index_or_string", - &[(I64, &arr_handle), (DOUBLE, &idx_double)], - )); + // #7640 section B: `!is_numeric_expr` does not restrict the + // index to a safe shape — `arr[f()]` is exactly this arm. + return rooting::with_operands_rooted(ctx, &[object, index], |ctx, vals| { + let (arr_box, idx_double) = (vals[0].clone(), vals[1].clone()); + let arr_handle = { + let blk = ctx.block(); + unbox_to_i64(blk, &arr_box) + }; + Ok(ctx.block().call( + DOUBLE, + "js_array_get_index_or_string", + &[(I64, &arr_handle), (DOUBLE, &idx_double)], + )) + }); } if numeric_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 arr_handle = { - let blk = ctx.block(); - unbox_to_i64(blk, &arr_box) - }; - return Ok(ctx.block().call( - DOUBLE, - "js_array_get_index_or_string", - &[(I64, &arr_handle), (DOUBLE, &idx_double)], - )); + // #7640 section B: `is_numeric_expr` is a TYPE predicate, + // not an effect-free one — a numeric-typed but unproven + // dynamic index (a getter, a call) is this arm's target. + return rooting::with_operands_rooted(ctx, &[object, index], |ctx, vals| { + let (arr_box, idx_double) = (vals[0].clone(), vals[1].clone()); + let arr_handle = { + let blk = ctx.block(); + unbox_to_i64(blk, &arr_box) + }; + Ok(ctx.block().call( + DOUBLE, + "js_array_get_index_or_string", + &[(I64, &arr_handle), (DOUBLE, &idx_double)], + )) + }); } let require_numeric_layout = expr_has_numeric_pointer_free_array_layout(ctx, object); @@ -1261,10 +1288,19 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { return rooting::with_operands_rooted(ctx, &[object, index], |ctx, vals| { let (obj_box, key_box) = (&vals[0], &vals[1]); let blk = ctx.block(); + // #7640 section D: the KEY is unboxed first. `unbox_str_handle` + // is not a mask — it calls `js_get_string_pointer_unified`, + // which materialises an SSO value into a fresh heap + // `StringHeader`, i.e. one allocation. Deriving the receiver's + // raw untagged pointer above it put a pointer NO ROOT CAN NAME + // across a potential collection point (#7280 taxonomy (a): a + // raw `i64` cannot be repaired by re-reading a `double` slot). + // Swapping the two lines closes it at zero runtime cost — + // the same two instructions, in the other order. + let key_handle = unbox_str_handle(blk, key_box); let obj_bits = blk.bitcast_double_to_i64(obj_box); let obj_handle = classref_preserving_handle(blk, &obj_bits, preserve_class_ref_bits); - let key_handle = unbox_str_handle(blk, key_box); let site_id = emit_typed_feedback_register_site( ctx, TypedFeedbackKind::PropertyGet, @@ -1350,9 +1386,20 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ctx.block().cond_br(&is_str, &str_lbl, &num_lbl); // String key → object field access. ctx.current_block = str_idx; - let key_handle = { + // #7640 section D, the cross-block half. `unbox_str_handle` + // calls `js_get_string_pointer_unified`, which materialises an + // SSO value into a fresh heap `StringHeader` — one allocation. + // The entry block's `obj_handle` is a RAW `i64` computed two + // conditional branches above it, so it crossed that allocation + // with no root able to name it. Re-derive it HERE, below the + // key unbox, from the boxed receiver. + let (key_handle, obj_handle) = { let blk = ctx.block(); - unbox_str_handle(blk, &idx_box) + let key_handle = unbox_str_handle(blk, &idx_box); + let obj_bits = blk.bitcast_double_to_i64(&obj_box); + let obj_handle = + classref_preserving_handle(blk, &obj_bits, preserve_class_ref_bits); + (key_handle, obj_handle) }; let site_id = emit_typed_feedback_register_site( ctx, diff --git a/crates/perry-codegen/src/expr/index_set.rs b/crates/perry-codegen/src/expr/index_set.rs index eee90249de..161d01647c 100644 --- a/crates/perry-codegen/src/expr/index_set.rs +++ b/crates/perry-codegen/src/expr/index_set.rs @@ -739,8 +739,11 @@ pub(crate) fn lower( let val_double = lower_expr(ctx, value)?; let (obj_handle, key_handle) = { let blk = ctx.block(); - let obj_handle = unbox_to_i64(blk, &global_box); + // #7640 section D: key first — `unbox_str_handle` can + // allocate (SSO materialisation), and a raw receiver pointer + // taken above it is unrootable. let key_handle = unbox_str_handle(blk, &key_box); + let obj_handle = unbox_to_i64(blk, &global_box); (obj_handle, key_handle) }; let site_id = emit_typed_feedback_register_site( @@ -997,8 +1000,9 @@ pub(crate) fn lower( let (arr_box, key_box) = (vals[0].clone(), vals[1].clone()); let (arr_handle, key_handle) = { let blk = ctx.block(); - let arr_handle = unbox_to_i64(blk, &arr_box); + // #7640 section D: key first (see the globalThis arm). let key_handle = unbox_str_handle(blk, &key_box); + let arr_handle = unbox_to_i64(blk, &arr_box); (arr_handle, key_handle) }; let site_id = emit_typed_feedback_register_site( @@ -1642,13 +1646,16 @@ pub(crate) fn lower( ); let (obj_handle, key_handle) = { let blk = ctx.block(); + // #7640 section D: the SSO-safe key unbox can + // allocate, so it goes FIRST — `obj_bits` is a + // NaN-boxed double the group re-read, but + // `obj_handle` is a raw `i64` no root can name. + let key_handle = unbox_str_handle(blk, &key_box); let obj_handle = super::index_get::classref_preserving_handle( blk, &obj_bits, static_classref, ); - // SSO-safe key unbox — see IndexGet branch above for rationale. - let key_handle = unbox_str_handle(blk, &key_box); (obj_handle, key_handle) }; let site_id = emit_typed_feedback_register_site( @@ -1773,9 +1780,20 @@ pub(crate) fn lower( // on captured arrays whose static type was lost across the // closure boundary (forEach callbacks, replace callbacks, etc.). ctx.current_block = str_set; - let key_handle = { + // #7640 section D, the cross-block half. `unbox_str_handle` + // can allocate (SSO materialisation), and the entry block's + // `obj_handle` is a RAW `i64` computed two conditional + // branches above it — nothing can name it across that + // allocation. Re-derive it here, below the key unbox. + let (key_handle, obj_handle) = { let blk = ctx.block(); - unbox_str_handle(blk, &idx_box) + let key_handle = unbox_str_handle(blk, &idx_box); + let obj_handle = super::index_get::classref_preserving_handle( + blk, + &obj_bits, + static_classref, + ); + (key_handle, obj_handle) }; ctx.block().call( I64, diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index fc5615dcda..7211c8de8e 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -22,7 +22,7 @@ use super::new_helpers::{ }; use crate::expr::{lower_expr, lower_js_args_array, nanbox_pointer_inline, FnCtx}; use crate::nanbox::{double_literal, POINTER_MASK_I64}; -use crate::rooting::{open_rooted_group, EmittedValue, Repr, RootedGroup}; +use crate::rooting::{self, open_rooted_group, EmittedValue, Repr, RootedGroup}; use crate::types::{DOUBLE, I32, I64, PTR}; /// Does `new (…)` run user code — an own or inherited constructor @@ -219,6 +219,31 @@ fn lower_new_impl( result } +/// Lower every constructor argument into `group`, rooting each one **as it is +/// produced** rather than after the list (#6969: rooting a finished list +/// publishes an already-dangling argument 0 to the scanner, which turns a +/// silent wrong answer into a SIGSEGV — strictly worse than not rooting). +/// +/// Returns the group indices, in argument order, for the caller to re-read at +/// the point it emits its call. `lower_constructor_arg` rather than +/// `RootedGroup::lower` because it clears `ctx.discard_expr_value` for the +/// operand — #7590: that flag means "this STATEMENT's value is discarded" and +/// is not cleared on recursion, so lowering an operand under it can evaluate a +/// typed-array store to `0`. +fn adopt_constructor_args<'a>( + ctx: &mut FnCtx<'_>, + args: &'a [Expr], + group: &mut RootedGroup<'a>, +) -> Result> { + let mut slots = Vec::with_capacity(args.len()); + for (i, a) in args.iter().enumerate() { + let value = lower_constructor_arg(ctx, a)?; + let collects = rooting::any_operand_may_collect(ctx, args[i + 1..].iter()); + slots.push(group.adopt(ctx, a, &value, collects)); + } + Ok(slots) +} + fn lower_new_impl_inner<'a>( ctx: &mut FnCtx<'_>, class_name: &str, @@ -243,15 +268,30 @@ fn lower_new_impl_inner<'a>( ctx.import_function_node_submodule.get(class_name).cloned() { if submod_key == "readline_promises" && exported_name == "Readline" { - let output = if let Some(first) = args.first() { - lower_expr(ctx, first)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + // #6986: `output` was live in an SSA register across `options`' + // lowering AND across every `extra`'s — each an arbitrary + // expression — before `js_readline_promises_readline_new` read + // it. Same repair as the main class loop below: adopt into the + // enclosing group as each operand is lowered (never after the + // list — that publishes an already-dangling pointer, #6969), + // and re-read at the call. + // + // The `undefined` fillers are literals, not operands: nothing + // to root, and `Arg::Plain` keeps them out of the group so an + // absent argument still costs no slot. + let output = match args.first() { + Some(first) => { + let collects = rooting::any_operand_may_collect(ctx, args[1..].iter()); + Some(group.lower(ctx, first, collects)?) + } + None => None, }; - let options = if let Some(second) = args.get(1) { - lower_expr(ctx, second)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + let options = match args.get(1) { + Some(second) => { + let collects = rooting::any_operand_may_collect(ctx, args[2..].iter()); + Some(group.lower(ctx, second, collects)?) + } + None => None, }; for extra in args.iter().skip(2) { let _ = lower_expr(ctx, extra)?; @@ -261,6 +301,15 @@ fn lower_new_impl_inner<'a>( DOUBLE, vec![DOUBLE, DOUBLE], )); + let undef = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + let output = match output { + Some(i) => group.reread(ctx, i)?, + None => undef.clone(), + }; + let options = match options { + Some(i) => group.reread(ctx, i)?, + None => undef, + }; return Ok(ctx.block().call( DOUBLE, "js_readline_promises_readline_new", @@ -344,6 +393,13 @@ fn lower_new_impl_inner<'a>( if ctx.import_function_prefixes.contains_key(class_name) && !ctx.import_function_v8_specifiers.contains_key(class_name) { + // #6986: `func_double` was live across every argument's + // lowering (arbitrary user code) and each argument across the + // ones after it, all of them in bare SSA registers, before + // `js_new_function_construct` read them. `lower_js_args_array` + // is no rescue — it is a plain `alloca_entry_array` pack with + // no `js_shadow_slot_bind`, so it copies whatever bits it is + // handed, stale or not. let func_double = lower_expr( ctx, &Expr::ExternFuncRef { @@ -352,11 +408,15 @@ fn lower_new_impl_inner<'a>( return_type: HirType::Any, }, )?; + let func_collects = rooting::any_operand_may_collect(ctx, args.iter()); + let func_root = group.adopt_emitted(ctx, Repr::Boxed, &func_double, func_collects); + let arg_slots = adopt_constructor_args(ctx, args, group)?; let mut lowered_args: Vec = Vec::with_capacity(args.len()); - for a in args { - lowered_args.push(lower_constructor_arg(ctx, a)?); + for slot in &arg_slots { + lowered_args.push(group.reread(ctx, *slot)?); } let (args_ptr, args_len) = lower_js_args_array(ctx, &lowered_args); + let func_double = group.reread_emitted(ctx, func_root); return Ok(ctx.block().call( DOUBLE, "js_new_function_construct", @@ -373,9 +433,14 @@ fn lower_new_impl_inner<'a>( // `send` → Next.js) and returns a working native function; anything // else still gets the placeholder. NO general JS interpreter. if class_name == "Function" { + // #6986: same shape as the imported-constructor branch above — + // argument `i` was live in a bare SSA register across every + // argument after it. `new Function(fresh(0), "return " + churn(N))` + // is the reproducer named in the issue. + let arg_slots = adopt_constructor_args(ctx, args, group)?; let mut lowered_args: Vec = Vec::with_capacity(args.len()); - for a in args { - lowered_args.push(lower_constructor_arg(ctx, a)?); + for slot in &arg_slots { + lowered_args.push(group.reread(ctx, *slot)?); } let (args_ptr, args_len) = lower_js_args_array(ctx, &lowered_args); return Ok(ctx.block().call( diff --git a/test-files/test_gap_7640_computed_key_windows.ts b/test-files/test_gap_7640_computed_key_windows.ts new file mode 100644 index 0000000000..485497362b --- /dev/null +++ b/test-files/test_gap_7640_computed_key_windows.ts @@ -0,0 +1,71 @@ +// #7640: computed read/write arms that lowered a receiver, then lowered more +// user code, then used the receiver. Behaviour must be unchanged by the rooting +// repair — this pins the semantics on every arm the change touches, including +// the section-D statement reorders (the key's SSO unbox now runs before the +// receiver's raw-pointer derivation). + +function alloc(n: number): string { + let s = ""; + for (let i = 0; i < n; i++) s += String(i % 10); + return s; +} + +// --- string receiver, side-effecting index (`s[f()]`) ----------------------- +const s = "abcdef"; +let calls = 0; +function idx(): number { + calls++; + alloc(200); + return 2; +} +console.log(s[idx()], calls); + +// --- array receiver, non-numeric computed key ------------------------------ +const arr: number[] = [10, 20, 30]; +const anyArr: any = arr; +anyArr.note = "hi"; +function keyOf(): string { + alloc(200); + return "note"; +} +console.log(arr[keyOf() as any]); + +// --- array receiver, numeric-typed but unproven index ---------------------- +function dynIndex(): number { + alloc(200); + return 1; +} +console.log(arr[dynIndex()]); + +// --- symbol key on an array and on a typed array --------------------------- +console.log(typeof arr[Symbol.iterator]); + +// --- SSO string key on an object, read and write --------------------------- +// The key is short enough to be stored inline (SSO), so `unbox_str_handle` +// materialises it into a fresh heap StringHeader — the allocation section D +// reorders around. +const obj: Record = {}; +const shortKey = "ab"; +obj[shortKey] = 7; +console.log(obj[shortKey]); +const dyn: any = "ab"; +obj[dyn] = 9; +console.log(obj[dyn], JSON.stringify(obj)); + +// --- globalThis[key] = v ---------------------------------------------------- +const g: any = globalThis; +const gk: any = "perryGapKey"; +g[gk] = { v: 1 }; +console.log(JSON.stringify(g[gk])); + +// --- array with a string key ------------------------------------------------ +const a2: any[] = [1, 2]; +const sk: any = "extra"; +a2[sk] = { z: 3 }; +console.log(JSON.stringify(a2[sk]), a2.length); + +// --- the byte-read fast path stays a byte read ------------------------------ +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); From 9475c9901829b83d4ada5f08e3ee0a561a986b31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 13:22:33 +0200 Subject: [PATCH 5/7] fix(codegen): the cross-block section-D handles need distinct names (#7640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-deriving `obj_handle` inside the string sub-block SHADOWED the entry block's, which the NUMERIC sibling block still uses — and a definition in the string block does not dominate it. The LLVM verifier rejected the module ("Instruction does not dominate all uses"), which took 10 corpus sources including test_gap_gc_index_get_receiver_rooting out of the root-dominance corpus as silent "skipped" entries. Distinct names; corpus back to 130/130. --- crates/perry-codegen/src/expr/index_get.rs | 12 ++++++++---- crates/perry-codegen/src/expr/index_set.rs | 11 +++++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/perry-codegen/src/expr/index_get.rs b/crates/perry-codegen/src/expr/index_get.rs index ea55a51fd5..f475023d33 100644 --- a/crates/perry-codegen/src/expr/index_get.rs +++ b/crates/perry-codegen/src/expr/index_get.rs @@ -1393,13 +1393,17 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // conditional branches above it, so it crossed that allocation // with no root able to name it. Re-derive it HERE, below the // key unbox, from the boxed receiver. - let (key_handle, obj_handle) = { + // Shadowing the entry block's `obj_handle` would be a verifier + // error, not a style choice: the NUMERIC sibling block below + // uses that one, and a definition in THIS block does not + // dominate it. + let (key_handle, str_obj_handle) = { let blk = ctx.block(); let key_handle = unbox_str_handle(blk, &idx_box); let obj_bits = blk.bitcast_double_to_i64(&obj_box); - let obj_handle = + let str_obj_handle = classref_preserving_handle(blk, &obj_bits, preserve_class_ref_bits); - (key_handle, obj_handle) + (key_handle, str_obj_handle) }; let site_id = emit_typed_feedback_register_site( ctx, @@ -1410,7 +1414,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let v_str = ctx.block().call( DOUBLE, "js_typed_feedback_object_get_field_by_name_f64", - &[(I64, &site_id), (I64, &obj_handle), (I64, &key_handle)], + &[(I64, &site_id), (I64, &str_obj_handle), (I64, &key_handle)], ); let str_end_lbl = ctx.block().label.clone(); ctx.block().br(&merge_lbl); diff --git a/crates/perry-codegen/src/expr/index_set.rs b/crates/perry-codegen/src/expr/index_set.rs index 161d01647c..854f353b97 100644 --- a/crates/perry-codegen/src/expr/index_set.rs +++ b/crates/perry-codegen/src/expr/index_set.rs @@ -1785,22 +1785,25 @@ pub(crate) fn lower( // `obj_handle` is a RAW `i64` computed two conditional // branches above it — nothing can name it across that // allocation. Re-derive it here, below the key unbox. - let (key_handle, obj_handle) = { + // A distinct name, not a shadow: the NUMERIC sibling block + // below uses the entry block's `obj_handle`, which a + // definition in THIS block does not dominate. + let (key_handle, str_obj_handle) = { let blk = ctx.block(); let key_handle = unbox_str_handle(blk, &idx_box); - let obj_handle = super::index_get::classref_preserving_handle( + let str_obj_handle = super::index_get::classref_preserving_handle( blk, &obj_bits, static_classref, ); - (key_handle, obj_handle) + (key_handle, str_obj_handle) }; ctx.block().call( I64, "js_typed_feedback_array_set_string_key", &[ (I64, &feedback_site_id), - (I64, &obj_handle), + (I64, &str_obj_handle), (I64, &key_handle), (DOUBLE, &val_double), ], From a1e1340c8e3b886f724d403a5f33435487632ee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 13:25:08 +0200 Subject: [PATCH 6/7] docs(changelog): key the fragments on PR #7699 --- ...691-member-update-rooting.md => 7699-member-update-rooting.md} | 0 ...mputed-key-windows.md => 7699-new-and-computed-key-windows.md} | 0 .../{7690-push-receiver-order.md => 7699-push-receiver-order.md} | 0 ...ification.md => 7699-uint8array-get-pointer-classification.md} | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{7691-member-update-rooting.md => 7699-member-update-rooting.md} (100%) rename changelog.d/{7693-new-and-computed-key-windows.md => 7699-new-and-computed-key-windows.md} (100%) rename changelog.d/{7690-push-receiver-order.md => 7699-push-receiver-order.md} (100%) rename changelog.d/{7692-uint8array-get-pointer-classification.md => 7699-uint8array-get-pointer-classification.md} (100%) diff --git a/changelog.d/7691-member-update-rooting.md b/changelog.d/7699-member-update-rooting.md similarity index 100% rename from changelog.d/7691-member-update-rooting.md rename to changelog.d/7699-member-update-rooting.md diff --git a/changelog.d/7693-new-and-computed-key-windows.md b/changelog.d/7699-new-and-computed-key-windows.md similarity index 100% rename from changelog.d/7693-new-and-computed-key-windows.md rename to changelog.d/7699-new-and-computed-key-windows.md diff --git a/changelog.d/7690-push-receiver-order.md b/changelog.d/7699-push-receiver-order.md similarity index 100% rename from changelog.d/7690-push-receiver-order.md rename to changelog.d/7699-push-receiver-order.md diff --git a/changelog.d/7692-uint8array-get-pointer-classification.md b/changelog.d/7699-uint8array-get-pointer-classification.md similarity index 100% rename from changelog.d/7692-uint8array-get-pointer-classification.md rename to changelog.d/7699-uint8array-get-pointer-classification.md From 8f88c10b7da048962c0ea287fad3b1a3c5c12bdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 14:09:31 +0200 Subject: [PATCH 7/7] chore: bump version to 0.5.1397 Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- CLAUDE.md | 2 +- Cargo.lock | 152 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 78 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 938d6bf0ab..a1d6728416 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1396 +**Current Version:** 0.5.1397 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 6ad6e538f0..7facfe1bc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1396" +version = "0.5.1397" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1396" +version = "0.5.1397" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1396" +version = "0.5.1397" [[package]] name = "perry-ui-tvos" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1396" +version = "0.5.1397" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 201efddd39..56a036892a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1396" +version = "0.5.1397" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"