From d0e6328179a24a71804f9bb23cc7a85e4839ed96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 10:34:58 +0200 Subject: [PATCH 1/2] codegen: field-push write-back on handle bits, not JS equality (#8897) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `field_push_local_bind` expanded `this.f.push(v)` into a receiver local, an inline `ArrayPush`, and `if (__push_recv !== __push_recv_old) this.f = __push_recv`. That guard is dead: a growing append leaves the old head as a forwarding stub to the new one and JS equality sees through forwarding (perry matches Node), so the field kept the stub and every later `this.f.length` / `this.f[i]` walked it through the dynamic property path — a 2.5x cold-phase regression in the wolf-ecs entity cycle that decayed only as the arrays stopped growing. `Expr::ArrayPush` now carries `field_writeback: Option`; the transform emits two statements (`let __push_recv = this.f; push`) and codegen compares the local's handle bits before and after the append — the one comparison that does not see through forwarding — re-pointing `this.f` through the ordinary class-field store when they differ, behind an inline plain-object header gate (frozen / sealed / no-extend / descriptor-bearing receivers keep the stub rather than risk a throw or an accessor). The tiny-method rule in `hot_callees` counts the two-statement expansion as the one authored statement; `stable_hash` hashes the new field and the monomorph substitution propagates it. Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ --- .../8897-field-push-writeback-handle-bits.md | 3 + .../perry-codegen-js/src/emit/exprs_more.rs | 4 +- .../src/emit/expr/arrays.rs | 4 +- .../src/emit/js_fallback.rs | 4 +- .../src/collectors/all_pointer_arrays.rs | 7 +- .../src/collectors/escape_check.rs | 2 +- .../perry-codegen/src/collectors/hir_facts.rs | 5 +- .../src/collectors/hot_callees.rs | 69 ++++----- .../perry-codegen/src/collectors/mutation.rs | 4 +- .../src/collectors/ptr_numarray.rs | 5 +- .../perry-codegen/src/collectors/ptr_shape.rs | 4 +- .../src/collectors/ptr_shape_elements.rs | 6 +- .../collectors/ptr_shape_elements_tests.rs | 1 + .../ptr_shape_group_numeric_tests.rs | 1 + crates/perry-codegen/src/collectors/refs.rs | 4 +- .../src/expr/array_callback_shape_tests.rs | 1 + crates/perry-codegen/src/expr/array_push.rs | 119 +++++++++++++- .../src/expr/array_push_guard_tests.rs | 111 +++++++++++++ .../src/expr/barrier_stem_census_tests.rs | 1 + .../src/stmt/element_shape_loop_tests.rs | 1 + crates/perry-codegen/src/stmt/loops.rs | 10 +- .../tests/large_object_barriers.rs | 1 + .../tests/native_proof_regressions.rs | 1 + .../native_proof_regressions/invalidation.rs | 6 + crates/perry-codegen/tests/typed_feedback.rs | 2 + .../tests/typed_shape_descriptors.rs | 2 + crates/perry-hir/src/analysis.rs | 6 +- crates/perry-hir/src/ir/expr.rs | 6 + .../perry-hir/src/lower/closure_analysis.rs | 4 +- .../lower/expr_call/local_array_methods.rs | 3 + .../src/monomorph/substitute_expr.rs | 7 +- crates/perry-hir/src/stable_hash/expr.rs | 2 +- .../src/deforest/call_sites.rs | 4 +- .../perry-transform/src/deforest/out_usage.rs | 6 +- .../src/deforest/producer_rewrite.rs | 4 +- crates/perry-transform/src/deforest/tests.rs | 5 + .../src/field_push_local_bind.rs | 146 ++++++++---------- .../src/generator/per_iteration.rs | 2 +- crates/perry-transform/src/unroll/mod.rs | 5 +- 39 files changed, 428 insertions(+), 150 deletions(-) create mode 100644 changelog.d/8897-field-push-writeback-handle-bits.md diff --git a/changelog.d/8897-field-push-writeback-handle-bits.md b/changelog.d/8897-field-push-writeback-handle-bits.md new file mode 100644 index 0000000000..ea30cb6ab1 --- /dev/null +++ b/changelog.d/8897-field-push-writeback-handle-bits.md @@ -0,0 +1,3 @@ +### Fixed + +- The `this.f.push(v)` expansion (`field_push_local_bind`) wrote the field back through a JS `!==` guard that never fired: a growing append leaves the old head forwarding to the new one and equality sees through forwarding, so the field kept the stub and every later `this.f.length` / `this.f[i]` took the dynamic property path (#8897 — a 2.5× cold-phase regression in the wolf-ecs entity cycle). `Expr::ArrayPush` now carries the field to write back and codegen compares the receiver local's handle bits before and after the append, re-pointing the field — behind an inline plain-object header gate — when they differ. diff --git a/crates/perry-codegen-js/src/emit/exprs_more.rs b/crates/perry-codegen-js/src/emit/exprs_more.rs index 1ff5147b66..ff29071fec 100644 --- a/crates/perry-codegen-js/src/emit/exprs_more.rs +++ b/crates/perry-codegen-js/src/emit/exprs_more.rs @@ -103,7 +103,9 @@ impl JsEmitter { } // --- Array methods --- - Expr::ArrayPush { array_id, value } => { + Expr::ArrayPush { + array_id, value, .. + } => { let name = self.get_local_name(*array_id); let _ = write!(self.output, "{}.push(", name); self.emit_expr(value); diff --git a/crates/perry-codegen-wasm/src/emit/expr/arrays.rs b/crates/perry-codegen-wasm/src/emit/expr/arrays.rs index d40fbb6732..863ca750e4 100644 --- a/crates/perry-codegen-wasm/src/emit/expr/arrays.rs +++ b/crates/perry-codegen-wasm/src/emit/expr/arrays.rs @@ -74,7 +74,9 @@ impl<'a> FuncEmitCtx<'a> { } } - Expr::ArrayPush { array_id, value } => { + Expr::ArrayPush { + array_id, value, .. + } => { self.emit_local_or_global_get(func, array_id); self.emit_frame_begin(func, 2); func.instruction(&Instruction::LocalSet(self.temp_local)); diff --git a/crates/perry-codegen-wasm/src/emit/js_fallback.rs b/crates/perry-codegen-wasm/src/emit/js_fallback.rs index 669959161f..8d3d557e13 100644 --- a/crates/perry-codegen-wasm/src/emit/js_fallback.rs +++ b/crates/perry-codegen-wasm/src/emit/js_fallback.rs @@ -617,7 +617,9 @@ impl WasmModuleEmitter { obj, k, val, val ) } - Expr::ArrayPush { array_id, value } => { + Expr::ArrayPush { + array_id, value, .. + } => { let arr = locals .get(array_id) .cloned() diff --git a/crates/perry-codegen/src/collectors/all_pointer_arrays.rs b/crates/perry-codegen/src/collectors/all_pointer_arrays.rs index 46ea09de50..f8aaaed176 100644 --- a/crates/perry-codegen/src/collectors/all_pointer_arrays.rs +++ b/crates/perry-codegen/src/collectors/all_pointer_arrays.rs @@ -25,7 +25,7 @@ //! would point the local at an array the declaration never covered, and the //! elided stores would then be describing the wrong object. //! 3. **Every store into it is a push of an allocation, and there is at least -//! one.** Every `Expr::ArrayPush { array_id: id }` pushes a fresh +//! one.** Every `Expr::ArrayPush { array_id: id, .. }` pushes a fresh //! allocation; no `Expr::ArrayPushSpread`, no `Expr::IndexSet` whose object //! is `LocalGet(id)` (an indexed store can jump past `length`, a different //! claim than "append"), no other in-place array mutation. @@ -168,7 +168,9 @@ pub(crate) fn collect_all_pointer_array_locals( Expr::ArrayPop(array_id) | Expr::ArrayShift(array_id) => { killed.insert(*array_id); } - Expr::ArrayPush { array_id, value } => { + Expr::ArrayPush { + array_id, value, .. + } => { if crate::expr::expr_produces_fresh_heap_allocation(value) { pushed.insert(*array_id); } else { @@ -258,6 +260,7 @@ mod tests { Stmt::Expr(Expr::ArrayPush { array_id: id, value: Box::new(value), + field_writeback: None, }) } diff --git a/crates/perry-codegen/src/collectors/escape_check.rs b/crates/perry-codegen/src/collectors/escape_check.rs index acbf95b805..6b29ca63c0 100644 --- a/crates/perry-codegen/src/collectors/escape_check.rs +++ b/crates/perry-codegen/src/collectors/escape_check.rs @@ -560,7 +560,7 @@ pub fn check_escapes_in_expr( check_escapes_in_expr(init, candidates, classes, escaped); } } - Expr::ArrayPush { array_id, value } => { + Expr::ArrayPush { array_id, value, .. } => { if candidates.contains_key(array_id) { escaped.insert(*array_id); } diff --git a/crates/perry-codegen/src/collectors/hir_facts.rs b/crates/perry-codegen/src/collectors/hir_facts.rs index f8ea18aea7..b7503ec070 100644 --- a/crates/perry-codegen/src/collectors/hir_facts.rs +++ b/crates/perry-codegen/src/collectors/hir_facts.rs @@ -1191,7 +1191,9 @@ impl ArrayFactCollector { fn collect_expr(&mut self, expr: &Expr) { match expr { - Expr::ArrayPush { array_id, value } => { + Expr::ArrayPush { + array_id, value, .. + } => { let value_kind = if expr_is_i32_shaped(value) { ArrayKindFact::PackedI32 } else if expr_is_numeric_shaped(value) { @@ -2297,6 +2299,7 @@ mod tests { Stmt::Expr(Expr::ArrayPush { array_id: 2, value: Box::new(Expr::Integer(4)), + field_writeback: None, }), ], &HashSet::new(), diff --git a/crates/perry-codegen/src/collectors/hot_callees.rs b/crates/perry-codegen/src/collectors/hot_callees.rs index e55d91a9be..53551c4af4 100644 --- a/crates/perry-codegen/src/collectors/hot_callees.rs +++ b/crates/perry-codegen/src/collectors/hot_callees.rs @@ -65,26 +65,24 @@ const TINY_METHOD_MAX_STMTS: usize = 2; const TINY_METHOD_ALLOC_SITE_BUDGET: u32 = 8; /// The receiver-binding local `perry-transform`'s `field_push_local_bind` -/// pass introduces when it expands one `this.f.push(v)` statement into four -/// (`let __push_recv_old = this.f; let __push_recv = old; push; if (moved) -/// this.f = __push_recv`). For the tiny-method budget above that is still the -/// ONE statement the author wrote: the pass exists so the push takes the +/// pass introduces when it expands one `this.f.push(v)` statement into two +/// (`let __push_recv = this.f; push` — the write-back rides on the +/// `ArrayPush` node itself). For the tiny-method budget above that is still +/// the ONE statement the author wrote: the pass exists so the push takes the /// inline append, and a command-buffer method that is exactly /// `this.commands.push({ ... })` must not lose its allocation kernel to the /// rewrite that made its push cheaper. Kept in sync by name with the pass /// (`field_push_local_bind.rs`); the test below pins the shape. -const FIELD_PUSH_RECEIVER_OLD_NAME: &str = "__push_recv_old"; +const FIELD_PUSH_RECEIVER_NAME: &str = "__push_recv"; /// Statement count for the tiny-method rule: each field-push expansion /// counts as the single statement it came from. fn tiny_method_stmt_count(body: &[Stmt]) -> usize { let expansions = body .iter() - .filter( - |stmt| matches!(stmt, Stmt::Let { name, .. } if name == FIELD_PUSH_RECEIVER_OLD_NAME), - ) + .filter(|stmt| matches!(stmt, Stmt::Let { name, .. } if name == FIELD_PUSH_RECEIVER_NAME)) .count(); - body.len().saturating_sub(3 * expansions) + body.len().saturating_sub(expansions) } /// Collect the set of `FuncId`s eligible for `inlinehint`: those with ≥1 direct @@ -866,43 +864,24 @@ mod recursion_participant_tests { } /// `this.commands.push({ ... })` after `field_push_local_bind` expanded it. - fn expanded_field_push(old_id: u32, recv_id: u32) -> Vec { + fn expanded_field_push(recv_id: u32) -> Vec { vec![ Stmt::Let { - id: old_id, - name: FIELD_PUSH_RECEIVER_OLD_NAME.to_string(), + id: recv_id, + name: FIELD_PUSH_RECEIVER_NAME.to_string(), ty: Type::Any, - mutable: false, + mutable: true, init: Some(Expr::PropertyGet { object: Box::new(Expr::This), property: "commands".to_string(), byte_offset: 0, }), }, - Stmt::Let { - id: recv_id, - name: "__push_recv".to_string(), - ty: Type::Any, - mutable: true, - init: Some(Expr::LocalGet(old_id)), - }, Stmt::Expr(Expr::ArrayPush { array_id: recv_id, value: Box::new(new_expr()), + field_writeback: Some("commands".to_string()), }), - Stmt::If { - condition: Expr::Compare { - op: perry_hir::CompareOp::Ne, - left: Box::new(Expr::LocalGet(recv_id)), - right: Box::new(Expr::LocalGet(old_id)), - }, - then_branch: vec![Stmt::Expr(Expr::PropertySet { - object: Box::new(Expr::This), - property: "commands".to_string(), - value: Box::new(Expr::LocalGet(recv_id)), - })], - else_branch: None, - }, ] } @@ -944,14 +923,18 @@ mod recursion_participant_tests { let mut module = Module::new("buffer.ts"); module .classes - .push(class_with_method(func(11, expanded_field_push(100, 101)))); - let mut plain = expanded_field_push(200, 201); - // Same four statements, but the first is an ordinary local: not an - // expansion, so the method is four statements long. + .push(class_with_method(func(11, expanded_field_push(101)))); + // Two copies back to back: two authored statements, not one. + let mut two = expanded_field_push(201); + two.extend(expanded_field_push(202)); + module.classes.push(class_with_method(func(12, two))); + // The same two statements, but the local is an ordinary one: not an + // expansion, so the method is two statements long. + let mut plain = expanded_field_push(301); if let Stmt::Let { name, .. } = &mut plain[0] { *name = "old".to_string(); } - module.classes.push(class_with_method(func(12, plain))); + module.classes.push(class_with_method(func(13, plain))); assert_eq!( tiny_method_stmt_count(&module.classes[0].methods[0].body), @@ -959,17 +942,17 @@ mod recursion_participant_tests { ); assert_eq!( tiny_method_stmt_count(&module.classes[1].methods[0].body), - 4 + 2 + ); + assert_eq!( + tiny_method_stmt_count(&module.classes[2].methods[0].body), + 2 ); let hot = collect_alloc_hot_functions(&module); assert!( hot.contains(&11), "the expanded field push is still a tiny kernel: {hot:?}" ); - assert!( - !hot.contains(&12), - "four unrelated statements are not: {hot:?}" - ); } #[test] diff --git a/crates/perry-codegen/src/collectors/mutation.rs b/crates/perry-codegen/src/collectors/mutation.rs index 18bfdd1f4e..be94c533d1 100644 --- a/crates/perry-codegen/src/collectors/mutation.rs +++ b/crates/perry-codegen/src/collectors/mutation.rs @@ -377,7 +377,9 @@ pub fn expr_has_mutation(e: &perry_hir::Expr, id: u32) -> bool { Expr::Object(props) => props.iter().any(|(_, v)| expr_has_mutation(v, id)), Expr::Closure { body, .. } => has_any_mutation(body, id), Expr::Sequence(es) => es.iter().any(|e| expr_has_mutation(e, id)), - Expr::ArrayPush { array_id, value } => *array_id == id || expr_has_mutation(value, id), + Expr::ArrayPush { + array_id, value, .. + } => *array_id == id || expr_has_mutation(value, id), Expr::ArraySplice { array_id, start, diff --git a/crates/perry-codegen/src/collectors/ptr_numarray.rs b/crates/perry-codegen/src/collectors/ptr_numarray.rs index 9e40bb5b20..33ab9b8eb9 100644 --- a/crates/perry-codegen/src/collectors/ptr_numarray.rs +++ b/crates/perry-codegen/src/collectors/ptr_numarray.rs @@ -774,7 +774,9 @@ impl<'a> UseWalk<'a> { // Numeric push keeps every invariant (canonical store through the // Phase 4a.1 tiers; growth writes the live head back to the // slot). A possibly-non-numeric push value disqualifies. - Expr::ArrayPush { array_id, value } => { + Expr::ArrayPush { + array_id, value, .. + } => { if self.is_candidate(*array_id) && !self.value_is_numeric(*array_id, value) { self.disq(*array_id); } @@ -955,6 +957,7 @@ mod tests { Expr::ArrayPush { array_id: id, value: Box::new(value), + field_writeback: None, } } diff --git a/crates/perry-codegen/src/collectors/ptr_shape.rs b/crates/perry-codegen/src/collectors/ptr_shape.rs index cb8ba1a1b0..c79765d553 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape.rs @@ -1291,7 +1291,9 @@ impl<'a> UseWalk<'a> { // are bounded by `collectors/ptr_shape_elements.rs` exactly as // rule 2 bounds an object local's, so no alias escapes the region. // Any other array, any other value shape, keeps today's escape. - Expr::ArrayPush { array_id, value } => { + Expr::ArrayPush { + array_id, value, .. + } => { self.disq(*array_id, report::ESC_CONTAINER_MUTATOR); // #7770: record the provenance argument list for the // group-wide numeric proof. Only pushes into a PROVEN array diff --git a/crates/perry-codegen/src/collectors/ptr_shape_elements.rs b/crates/perry-codegen/src/collectors/ptr_shape_elements.rs index ce6486adb7..b250aaa574 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_elements.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_elements.rs @@ -31,7 +31,7 @@ //! carry elisions (`[,,]` — holes that read back as `undefined`), and //! admitting one buys nothing that the pushes below do not. //! * **E2 — element provenance.** Every write into `A` is -//! `Expr::ArrayPush { array_id: A }` whose value is `new C(...)` — inline, +//! `Expr::ArrayPush { array_id: A, .. }` whose value is `new C(...)` — inline, //! or a local bound by exactly one `Let { init: New { C } }` and pushed //! exactly once. `Expr::New` covers closed object literals too //! (`__AnonShape_…`), so records qualify. Perry class constructors cannot @@ -1066,7 +1066,9 @@ impl<'a> ArrayWalk<'a> { self.walk_expr(object); } // E2: the one admitted write. - Expr::ArrayPush { array_id, value } => { + Expr::ArrayPush { + array_id, value, .. + } => { if let Some(root) = self.root_of(*array_id) { let site = match value.as_ref() { Expr::New { class_name, .. } => PushValue::Fresh(class_name.clone()), diff --git a/crates/perry-codegen/src/collectors/ptr_shape_elements_tests.rs b/crates/perry-codegen/src/collectors/ptr_shape_elements_tests.rs index 7412de40be..a6d9caae7d 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_elements_tests.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_elements_tests.rs @@ -110,6 +110,7 @@ fn push(array_id: u32, value: Expr) -> Stmt { Stmt::Expr(Expr::ArrayPush { array_id, value: Box::new(value), + field_writeback: None, }) } diff --git a/crates/perry-codegen/src/collectors/ptr_shape_group_numeric_tests.rs b/crates/perry-codegen/src/collectors/ptr_shape_group_numeric_tests.rs index 26f5f0512f..82c52de4c0 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_group_numeric_tests.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_group_numeric_tests.rs @@ -152,6 +152,7 @@ fn push(array_id: u32, value: Expr) -> Stmt { Stmt::Expr(Expr::ArrayPush { array_id, value: Box::new(value), + field_writeback: None, }) } diff --git a/crates/perry-codegen/src/collectors/refs.rs b/crates/perry-codegen/src/collectors/refs.rs index c83d409453..e80bf3e380 100644 --- a/crates/perry-codegen/src/collectors/refs.rs +++ b/crates/perry-codegen/src/collectors/refs.rs @@ -348,7 +348,9 @@ pub fn collect_ref_ids_in_expr(e: &perry_hir::Expr, out: &mut HashSet) { walk(index, out); walk(value, out); } - Expr::ArrayPush { array_id, value } => { + Expr::ArrayPush { + array_id, value, .. + } => { out.insert(*array_id); walk(value, out); } diff --git a/crates/perry-codegen/src/expr/array_callback_shape_tests.rs b/crates/perry-codegen/src/expr/array_callback_shape_tests.rs index 5418bcbfc5..7d38e08898 100644 --- a/crates/perry-codegen/src/expr/array_callback_shape_tests.rs +++ b/crates/perry-codegen/src/expr/array_callback_shape_tests.rs @@ -112,6 +112,7 @@ fn module_with_callback(declare_source_array_param: bool) -> Module { byte_offset: 0, cap_args_appended: 0, }), + field_writeback: None, }), Stmt::Expr(Expr::ArrayForEach { array: Box::new(Expr::LocalGet(ARRAY_ID)), diff --git a/crates/perry-codegen/src/expr/array_push.rs b/crates/perry-codegen/src/expr/array_push.rs index 946b867200..fd83af0a10 100644 --- a/crates/perry-codegen/src/expr/array_push.rs +++ b/crates/perry-codegen/src/expr/array_push.rs @@ -635,9 +635,124 @@ fn lower_array_push_spread_spec_order( }) } +/// Flags in the object's `GcHeader::_reserved` word any of which takes the +/// receiver off the plain-data-property path: `OBJ_FLAG_FROZEN` (0x01), +/// `OBJ_FLAG_SEALED` (0x02), `OBJ_FLAG_NO_EXTEND` (0x04) and +/// `OBJ_FLAG_HAS_DESCRIPTORS` (0x800). The field write-back below is a +/// repair, not a user store: on such a receiver it is skipped rather than +/// risk a throw or an accessor, and the field keeps working through the +/// forwarding stub as it always did. +const FIELD_WRITEBACK_BLOCKING_FLAGS_I16: &str = "2055"; +const POINTER_TAG_HI16: &str = "32765"; // 0x7FFD +const HANDLE_BAND_TOP: &str = "1048575"; // 0x0FFFFF — objects are above +const HANDLE_MASK_48: &str = "281474976710655"; // 0x0000_FFFF_FFFF_FFFF +const GC_TYPE_OBJECT_I8: &str = "2"; + +/// `Expr::ArrayPush`. When `field_writeback` names a class field (the +/// `perry-transform::field_push_local_bind` expansion of `this.f.push(v)`), +/// the append is followed by the field write-back the HIR cannot express: +/// the receiver local's HANDLE BITS are compared before and after the push +/// and, when they differ, `this.f` is re-pointed at the local. A JS-level +/// `!==` cannot do this — a growing append leaves the old head forwarding to +/// the new one and equality sees through forwarding (#8897), so the field +/// would keep the stub and every later `this.f.length` / `this.f[i]` would +/// take the dynamic property path. pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, value_discarded: bool) -> Result { + let Expr::ArrayPush { + array_id, + field_writeback: Some(field), + .. + } = expr + else { + return lower_inner(ctx, expr, value_discarded); + }; + // The bits BEFORE the append, held as an integer, never a pointer: a + // collection inside the push may move the array and refresh the rooted + // local, and stale bits then compare unequal — the conservative + // direction (one redundant re-point of the same object). + let before_box = lower_expr(ctx, &Expr::LocalGet(*array_id))?; + let before_bits = ctx.block().bitcast_double_to_i64(&before_box); + let result = lower_inner(ctx, expr, value_discarded)?; + emit_field_push_writeback(ctx, *array_id, field, &before_bits)?; + Ok(result) +} + +/// The write-back half of [`lower`]: `if (bits(local) != before && +/// this is a plain object) this. = local`, as an ordinary +/// `PropertySet` lowering (class-field IC, barrier and layout note included) +/// behind two inline tests. The header test is what makes the repair +/// unobservable: it runs only on a `GC_TYPE_OBJECT` receiver with none of +/// [`FIELD_WRITEBACK_BLOCKING_FLAGS_I16`] set, i.e. a plain data-property +/// store of the very object the field already names. +fn emit_field_push_writeback( + ctx: &mut FnCtx<'_>, + array_id: u32, + field: &str, + before_bits: &str, +) -> Result<()> { + let after_box = lower_expr(ctx, &Expr::LocalGet(array_id))?; + let this_box = lower_expr(ctx, &Expr::This)?; + + let deref_idx = ctx.new_block("apush.field.deref"); + let store_idx = ctx.new_block("apush.field.writeback"); + let done_idx = ctx.new_block("apush.field.done"); + let deref_label = ctx.block_label(deref_idx); + let store_label = ctx.block_label(store_idx); + let done_label = ctx.block_label(done_idx); + + { + let blk = ctx.block(); + let after_bits = blk.bitcast_double_to_i64(&after_box); + let same = blk.icmp_eq(I64, &after_bits, before_bits); + let this_bits = blk.bitcast_double_to_i64(&this_box); + let tag = blk.lshr(I64, &this_bits, "48"); + let is_ptr = blk.icmp_eq(I64, &tag, POINTER_TAG_HI16); + let handle = blk.and(I64, &this_bits, HANDLE_MASK_48); + let above_band = blk.icmp_ugt(I64, &handle, HANDLE_BAND_TOP); + let ptr_ok = blk.and(I1, &is_ptr, &above_band); + let moved = blk.icmp_eq(I1, &same, "false"); + let deref = blk.and(I1, &moved, &ptr_ok); + blk.cond_br(&deref, &deref_label, &done_label); + } + + ctx.current_block = deref_idx; + { + let blk = ctx.block(); + let this_bits = blk.bitcast_double_to_i64(&this_box); + let handle = blk.and(I64, &this_bits, HANDLE_MASK_48); + let obj_ptr = blk.inttoptr(I64, &handle); + // GcHeader precedes the object: obj_type @-8 (i8), _reserved @-6 (i16). + let gtype_ptr = blk.gep(I8, &obj_ptr, &[(I64, "-8")]); + let gtype = blk.load(I8, >ype_ptr); + let is_object = blk.icmp_eq(I8, >ype, GC_TYPE_OBJECT_I8); + let res_ptr = blk.gep(I8, &obj_ptr, &[(I64, "-6")]); + let reserved = blk.load(I16, &res_ptr); + let blocking = blk.and(I16, &reserved, FIELD_WRITEBACK_BLOCKING_FLAGS_I16); + let plain = blk.icmp_eq(I16, &blocking, "0"); + let store_ok = blk.and(I1, &is_object, &plain); + blk.cond_br(&store_ok, &store_label, &done_label); + } + + ctx.current_block = store_idx; + lower_expr( + ctx, + &Expr::PropertySet { + object: Box::new(Expr::This), + property: field.to_string(), + value: Box::new(Expr::LocalGet(array_id)), + }, + )?; + ctx.block().br(&done_label); + + ctx.current_block = done_idx; + Ok(()) +} + +fn lower_inner(ctx: &mut FnCtx<'_>, expr: &Expr, value_discarded: bool) -> Result { match expr { - Expr::ArrayPush { array_id, value } => { + Expr::ArrayPush { + array_id, value, .. + } => { // Resolve the array storage in priority order: closure // capture (slot in the closure header), local alloca slot, // module-level global. The realloc-pointer write-back must @@ -1318,6 +1433,7 @@ mod receiver_order_tests { Stmt::Expr(Expr::ArrayPush { array_id: 0, value: Box::new(value), + field_writeback: None, }), Stmt::Return(Some(Expr::LocalGet(0))), ], @@ -1466,6 +1582,7 @@ mod parent_gate_tests { Stmt::Expr(Expr::ArrayPush { array_id: 0, value: Box::new(Expr::Object(vec![("v".to_string(), Expr::Number(1.0))])), + field_writeback: None, }), Stmt::Return(Some(Expr::LocalGet(0))), ], diff --git a/crates/perry-codegen/src/expr/array_push_guard_tests.rs b/crates/perry-codegen/src/expr/array_push_guard_tests.rs index 38e280a006..3e3f7f86a5 100644 --- a/crates/perry-codegen/src/expr/array_push_guard_tests.rs +++ b/crates/perry-codegen/src/expr/array_push_guard_tests.rs @@ -187,6 +187,7 @@ fn push_module(elem: Type, value: Expr, classes: Vec) -> Module { body: vec![Stmt::Expr(Expr::ArrayPush { array_id: ARRAY_ID, value: Box::new(value), + field_writeback: None, })], }, ], @@ -402,6 +403,7 @@ fn element_read_push_module() -> Module { object: Box::new(Expr::LocalGet(ARRAY_ID)), index: Box::new(Expr::LocalGet(COUNTER_ID)), }), + field_writeback: None, }); m } @@ -482,3 +484,112 @@ fn a_metadata_selected_add_keeps_the_runtime_number_guard() { "metadata alone must not reach the pointer-only inline bookkeeping guard:\n{generic}" ); } + +/// `class Buffer { items: number[]; add() { this.items.push(1) } }` as +/// `perry-transform::field_push_local_bind` leaves it: the receiver local plus +/// an `ArrayPush` that carries the field to write back. +fn field_push_module(field_writeback: Option) -> Module { + const RECV_ID: u32 = 40; + let method = Function { + id: 710, + name: "add".to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Void, + body: vec![ + Stmt::Let { + id: RECV_ID, + name: "__push_recv".to_string(), + ty: Type::Array(Box::new(Type::Number)), + mutable: true, + init: Some(Expr::PropertyGet { + object: Box::new(Expr::This), + property: "items".to_string(), + byte_offset: 0, + }), + }, + Stmt::Expr(Expr::ArrayPush { + array_id: RECV_ID, + value: Box::new(Expr::Number(1.0)), + field_writeback, + }), + ], + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }; + let mut class = node_class(); + class.id = 405; + class.name = "Buffer".to_string(); + class.fields = vec![ClassField { + name: "items".to_string(), + key_expr: None, + ty: Type::Array(Box::new(Type::Number)), + init: None, + is_private: false, + is_readonly: false, + decorators: Vec::new(), + }]; + class.methods = vec![method]; + let mut m = Module::new("array_push_field_writeback.ts"); + m.classes = vec![class]; + m.init = vec![Stmt::Expr(Expr::New { + class_name: "Buffer".to_string(), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + })]; + m.init_kind = ModuleInitKind::Eager; + m +} + +/// #8897: the field write-back after `this.items.push(v)` must be decided on +/// the receiver local's HANDLE BITS, not on JS equality — equality sees +/// through the growth-forwarding stub a re-allocating append leaves behind, +/// so a `!==` guard never fired and the field kept the stub. The IR must +/// contain the bits compare, the plain-object header gate (the repair is +/// skipped on frozen / sealed / descriptor-bearing receivers rather than +/// throwing), and the field store behind it; a push without a write-back +/// target must emit none of it. +#[test] +fn a_field_push_writes_the_field_back_on_a_handle_bits_change_behind_a_plain_object_gate() { + let ir = ir_for(field_push_module(Some("items".to_string()))); + let body = function_body(&ir, "add"); + assert!( + body.contains("apush.field.writeback"), + "the write-back arm must exist:\n{body}" + ); + let deref = super::class_field_barrier_tests::block_body(body, "apush.field.deref") + .expect("the header gate block is defined"); + assert!( + deref.contains("and i16 %") && deref.contains(", 2055"), + "the gate must test the frozen/sealed/no-extend/descriptor flags in one mask:\n{deref}" + ); + assert!( + deref.contains("icmp eq i8 %") && deref.contains(", 2\n"), + "the gate must require a GC_TYPE_OBJECT receiver:\n{deref}" + ); + // The store arm is the ordinary class-field set: the inline IC store + // with its barrier, and the descriptor-aware runtime fallback behind it. + assert!( + body.contains("class_field_set.fast") && body.contains("@js_class_field_set_fallback("), + "the write-back arm must be the class-field store:\n{body}" + ); + assert!( + body.contains("icmp eq i64 %"), + "the decision must be a handle-bits compare, not a JS equality:\n{body}" + ); + + let plain = ir_for(field_push_module(None)); + let plain_body = function_body(&plain, "add"); + assert!( + !plain_body.contains("apush.field.") && !plain_body.contains("class_field_set."), + "a push with no write-back target must emit neither the field arm nor a field store" + ); +} diff --git a/crates/perry-codegen/src/expr/barrier_stem_census_tests.rs b/crates/perry-codegen/src/expr/barrier_stem_census_tests.rs index 80b6200dc5..2ba50f34ff 100644 --- a/crates/perry-codegen/src/expr/barrier_stem_census_tests.rs +++ b/crates/perry-codegen/src/expr/barrier_stem_census_tests.rs @@ -473,6 +473,7 @@ fn apush_ir() -> String { Stmt::Expr(Expr::ArrayPush { array_id: ARR_ID, value: Box::new(Expr::Object(vec![("v".to_string(), Expr::Number(1.0))])), + field_writeback: None, }), Stmt::Return(Some(Expr::LocalGet(ARR_ID))), ], diff --git a/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs b/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs index 3a65f9e7ef..6abe3a214b 100644 --- a/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs +++ b/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs @@ -493,6 +493,7 @@ fn exact_local_array_construction_skips_the_runtime_shape_scan() { byte_offset: 0, cap_args_appended: 0, }), + field_writeback: None, }), ); diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index 949a86d579..3e31f96d03 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -6753,7 +6753,9 @@ fn expr_array_length_effect( expr_array_length_effect(ctx, sub, arr_id, bounded_idx_id, has_strict_bound, aliases) }; match e { - Expr::ArrayPush { array_id, value } => { + Expr::ArrayPush { + array_id, value, .. + } => { if local_may_alias_guarded_array(ctx, arr_id, *array_id, aliases) { LoopArrayLengthEffect::AliasLengthMutation } else { @@ -7213,9 +7215,9 @@ pub(crate) fn expr_preserves_array_length( expr_preserves_array_length(ctx, sub, arr_id, bounded_idx_id, has_strict_bound, aliases) }; match e { - Expr::ArrayPush { array_id, value } => { - !local_may_alias_guarded_array(ctx, arr_id, *array_id, aliases) && walk(value) - } + Expr::ArrayPush { + array_id, value, .. + } => !local_may_alias_guarded_array(ctx, arr_id, *array_id, aliases) && walk(value), Expr::ArrayPop(id) | Expr::ArrayShift(id) => { !local_may_alias_guarded_array(ctx, arr_id, *id, aliases) } diff --git a/crates/perry-codegen/tests/large_object_barriers.rs b/crates/perry-codegen/tests/large_object_barriers.rs index c38b1a07a7..af649db0dc 100644 --- a/crates/perry-codegen/tests/large_object_barriers.rs +++ b/crates/perry-codegen/tests/large_object_barriers.rs @@ -180,6 +180,7 @@ fn module_with_large_local_array_push(element_count: usize) -> Module { Stmt::Expr(Expr::ArrayPush { array_id: 2, value: Box::new(Expr::LocalGet(1)), + field_writeback: None, }), Stmt::Return(Some(Expr::LocalGet(2))), ], diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index 0504316cfb..1791f94b4a 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -6620,6 +6620,7 @@ fn artifact_records_array_push_value_bits_before_slot_store() { Stmt::Expr(Expr::ArrayPush { array_id: 1, value: Box::new(local(2)), + field_writeback: None, }), Stmt::Return(Some(int(0))), ], diff --git a/crates/perry-codegen/tests/native_proof_regressions/invalidation.rs b/crates/perry-codegen/tests/native_proof_regressions/invalidation.rs index 260d7d6fae..1b558baa91 100644 --- a/crates/perry-codegen/tests/native_proof_regressions/invalidation.rs +++ b/crates/perry-codegen/tests/native_proof_regressions/invalidation.rs @@ -702,6 +702,7 @@ fn local_array_alias_push_blocks_length_and_bounds_proofs() { let body = aliased_array_loop(Expr::ArrayPush { array_id: 2, value: Box::new(int(1)), + field_writeback: None, }); let ir = compile_ir("array_alias_push_blocks_loop_proof.ts", body); @@ -766,6 +767,7 @@ fn indirect_array_alias_from_container_blocks_length_and_bounds_proofs() { Stmt::Expr(Expr::ArrayPush { array_id: 6, value: Box::new(int(1)), + field_writeback: None, }), array_set(1, local(2), local(2)), ], @@ -1017,6 +1019,7 @@ fn loop_local_array_alias_blocks_length_and_bounds_proofs() { Stmt::Expr(Expr::ArrayPush { array_id: 3, value: Box::new(int(1)), + field_writeback: None, }), array_set(1, local(2), local(2)), ], @@ -1041,6 +1044,7 @@ fn loop_local_array_alias_push_blocks_packed_f64_loop_and_artifacts() { Stmt::Expr(Expr::ArrayPush { array_id: 2, value: Box::new(int(4)), + field_writeback: None, }), Stmt::Expr(Expr::LocalSet( 3, @@ -1071,6 +1075,7 @@ fn loop_local_array_alias_push_blocks_packed_i32_loop_and_artifacts() { Stmt::Expr(Expr::ArrayPush { array_id: 2, value: Box::new(int(4)), + field_writeback: None, }), Stmt::Expr(Expr::LocalSet( 3, @@ -1102,6 +1107,7 @@ fn loop_local_array_alias_push_blocks_packed_u32_loop_and_artifacts() { Stmt::Expr(Expr::ArrayPush { array_id: 2, value: Box::new(int(5)), + field_writeback: None, }), Stmt::Expr(Expr::LocalSet( 3, diff --git a/crates/perry-codegen/tests/typed_feedback.rs b/crates/perry-codegen/tests/typed_feedback.rs index 364fd02870..e24de1626d 100644 --- a/crates/perry-codegen/tests/typed_feedback.rs +++ b/crates/perry-codegen/tests/typed_feedback.rs @@ -1071,6 +1071,7 @@ fn typed_feedback_guards_numeric_array_push_specialization() { Stmt::Expr(Expr::ArrayPush { array_id: 1, value: Box::new(Expr::Number(7.0)), + field_writeback: None, }), Stmt::Return(Some(Expr::LocalGet(1))), ], @@ -1143,6 +1144,7 @@ fn typed_feedback_inline_array_writes_note_numeric_downgrade() { Stmt::Expr(Expr::ArrayPush { array_id: 2, value: Box::new(Expr::String("still-not-number".to_string())), + field_writeback: None, }), Stmt::Return(Some(Expr::LocalGet(2))), ], diff --git a/crates/perry-codegen/tests/typed_shape_descriptors.rs b/crates/perry-codegen/tests/typed_shape_descriptors.rs index 505941615d..3fbcef7e4b 100644 --- a/crates/perry-codegen/tests/typed_shape_descriptors.rs +++ b/crates/perry-codegen/tests/typed_shape_descriptors.rs @@ -398,6 +398,7 @@ fn number_typed_local_array_push_keeps_layout_note_and_barrier() { Stmt::Expr(Expr::ArrayPush { array_id: 2, value: Box::new(Expr::LocalGet(1)), + field_writeback: None, }), Stmt::Return(Some(Expr::LocalGet(2))), ], @@ -722,6 +723,7 @@ fn integer_arithmetic_array_push_omits_inbounds_layout_note_and_barrier() { left: Box::new(Expr::LocalGet(2)), right: Box::new(Expr::Number(1.5)), }), + field_writeback: None, })], }, Stmt::Return(Some(Expr::LocalGet(1))), diff --git a/crates/perry-hir/src/analysis.rs b/crates/perry-hir/src/analysis.rs index f5aa9d0479..8f4d2c8e38 100644 --- a/crates/perry-hir/src/analysis.rs +++ b/crates/perry-hir/src/analysis.rs @@ -359,7 +359,9 @@ pub(crate) fn collect_assigned_locals_expr(expr: &Expr, assigned: &mut Vec, this_id: LocalId) { /// Issue #212: rewrite every `LocalGet(old_id)` / `LocalSet(old_id, _)` / /// `Update { id: old_id, .. }` reference (plus the LocalId fields baked -/// into specialized HIR variants like `Expr::ArrayPush { array_id }` and +/// into specialized HIR variants like `Expr::ArrayPush { array_id, .. }` and /// the `captures` / `mutable_captures` lists on `Expr::Closure`) where /// `old_id` appears as a key in `map`, replacing it with the corresponding /// `new_id`. Used by `lower_class_decl` to remap captured outer-fn diff --git a/crates/perry-hir/src/ir/expr.rs b/crates/perry-hir/src/ir/expr.rs index 3a72c08a1c..b533207be5 100644 --- a/crates/perry-hir/src/ir/expr.rs +++ b/crates/perry-hir/src/ir/expr.rs @@ -1600,6 +1600,12 @@ pub enum Expr { ArrayPush { array_id: LocalId, value: Box, + /// `Some(field)` when `perry-transform::field_push_local_bind` bound + /// `this..push(value)` to the local `array_id`: after the push, + /// codegen re-points `this.` at the local's head when the + /// append re-allocated it (compared by handle bits — JS equality sees + /// through growth forwarding, so it cannot express this). + field_writeback: Option, }, // arr.push(value) -> new length ArrayPushSpread { array_id: LocalId, diff --git a/crates/perry-hir/src/lower/closure_analysis.rs b/crates/perry-hir/src/lower/closure_analysis.rs index 8528fd21a5..e9cacc1b6e 100644 --- a/crates/perry-hir/src/lower/closure_analysis.rs +++ b/crates/perry-hir/src/lower/closure_analysis.rs @@ -1416,7 +1416,9 @@ fn collect_closure_assigned_in_body_expr( } // Array mutation methods may reallocate the array pointer, so they // count as assignments to the array_id for mutable-capture widening. - Expr::ArrayPush { array_id, value } + Expr::ArrayPush { + array_id, value, .. + } | Expr::ArrayUnshift { array_id, value } | Expr::ArrayPushSpread { array_id, diff --git a/crates/perry-hir/src/lower/expr_call/local_array_methods.rs b/crates/perry-hir/src/lower/expr_call/local_array_methods.rs index b0962bbcc0..99a7807ca3 100644 --- a/crates/perry-hir/src/lower/expr_call/local_array_methods.rs +++ b/crates/perry-hir/src/lower/expr_call/local_array_methods.rs @@ -395,6 +395,7 @@ pub(super) fn try_local_array_methods( stmts.push(Expr::ArrayPush { array_id, value: Box::new(arg), + field_writeback: None, }); } } @@ -404,6 +405,7 @@ pub(super) fn try_local_array_methods( return Ok(Ok(Expr::ArrayPush { array_id, value: Box::new(args.into_iter().next().unwrap()), + field_writeback: None, })); } let mut stmts: Vec = Vec::with_capacity(args.len()); @@ -411,6 +413,7 @@ pub(super) fn try_local_array_methods( stmts.push(Expr::ArrayPush { array_id, value: Box::new(a), + field_writeback: None, }); } return Ok(Ok(Expr::Sequence(stmts))); diff --git a/crates/perry-hir/src/monomorph/substitute_expr.rs b/crates/perry-hir/src/monomorph/substitute_expr.rs index e7ff4cb38b..6fc143be7f 100644 --- a/crates/perry-hir/src/monomorph/substitute_expr.rs +++ b/crates/perry-hir/src/monomorph/substitute_expr.rs @@ -487,9 +487,14 @@ pub(crate) fn substitute_expr(expr: &Expr, substitutions: &HashMap ), // Array methods - Expr::ArrayPush { array_id, value } => Expr::ArrayPush { + Expr::ArrayPush { + array_id, + value, + field_writeback, + } => Expr::ArrayPush { array_id: *array_id, value: Box::new(substitute_expr(value, substitutions)), + field_writeback: field_writeback.clone(), }, Expr::ArrayPushSpread { array_id, source } => Expr::ArrayPushSpread { array_id: *array_id, diff --git a/crates/perry-hir/src/stable_hash/expr.rs b/crates/perry-hir/src/stable_hash/expr.rs index 680cd7baad..60bbf6b991 100644 --- a/crates/perry-hir/src/stable_hash/expr.rs +++ b/crates/perry-hir/src/stable_hash/expr.rs @@ -374,7 +374,7 @@ impl SH for Expr { Expr::NetCreateServer { options, connection_listener, } => { tag(h, 246); options.hash(h); connection_listener.hash(h); } Expr::NetCreateConnection { port, host, connect_listener, } => { tag(h, 247); port.as_ref().hash(h); host.hash(h); connect_listener.hash(h); } Expr::NetConnect { port, host, connect_listener, } => { tag(h, 248); port.as_ref().hash(h); host.hash(h); connect_listener.hash(h); } - Expr::ArrayPush { array_id, value } => { tag(h, 249); array_id.hash(h); value.as_ref().hash(h); } + Expr::ArrayPush { array_id, value, field_writeback } => { tag(h, 249); array_id.hash(h); value.as_ref().hash(h); field_writeback.hash(h); } Expr::ArrayPushSpread { array_id, source } => { tag(h, 250); array_id.hash(h); source.as_ref().hash(h); } Expr::ArrayPop(id) => { tag(h, 251); id.hash(h); } Expr::ArrayShift(id) => { tag(h, 252); id.hash(h); } diff --git a/crates/perry-transform/src/deforest/call_sites.rs b/crates/perry-transform/src/deforest/call_sites.rs index 2d7dd8eeb4..b75493f0b2 100644 --- a/crates/perry-transform/src/deforest/call_sites.rs +++ b/crates/perry-transform/src/deforest/call_sites.rs @@ -356,7 +356,9 @@ fn match_consume_loop( // Match either ArrayPush { array: LocalGet(outer), value: IndexGet { ... } } // OR Call { callee: PropertyGet { LocalGet(outer), "push" }, args: [IndexGet...] } match push_call { - Expr::ArrayPush { array_id, value } => { + Expr::ArrayPush { + array_id, value, .. + } => { if !is_index_get_of(value, child_id, j_id) { return None; } diff --git a/crates/perry-transform/src/deforest/out_usage.rs b/crates/perry-transform/src/deforest/out_usage.rs index b76ffe9bc1..53614170ff 100644 --- a/crates/perry-transform/src/deforest/out_usage.rs +++ b/crates/perry-transform/src/deforest/out_usage.rs @@ -5,7 +5,7 @@ use super::*; /// Walks an HIR subtree looking for unsafe uses of a target local. /// "Safe" uses are limited to: -/// - `out.push(value)` — both as `Expr::ArrayPush { array: LocalGet(out), value }` +/// - `out.push(value)` — both as `Expr::ArrayPush { array: LocalGet(out), value, .. }` /// and as a generic `Expr::Call { callee: PropertyGet { LocalGet(out), "push" } }`. /// - `out[index]` reads (Expr::IndexGet) — they don't escape the /// array, and the rewrite doesn't change their semantics. @@ -123,7 +123,9 @@ impl OutUsageAnalyzer { // below (which would otherwise flag the LocalGet(out) inside // them as unsafe). match e { - Expr::ArrayPush { array_id, value } if *array_id == self.out_id => { + Expr::ArrayPush { + array_id, value, .. + } if *array_id == self.out_id => { // Safe: out.push(v). Visit only `value`. self.visit_expr(value); return; diff --git a/crates/perry-transform/src/deforest/producer_rewrite.rs b/crates/perry-transform/src/deforest/producer_rewrite.rs index 894860e1fd..2bf120c02b 100644 --- a/crates/perry-transform/src/deforest/producer_rewrite.rs +++ b/crates/perry-transform/src/deforest/producer_rewrite.rs @@ -201,7 +201,9 @@ impl SubstituteLocal { *id = self.to; return; } - Expr::ArrayPush { array_id, value } => { + Expr::ArrayPush { + array_id, value, .. + } => { if *array_id == self.from { *array_id = self.to; } diff --git a/crates/perry-transform/src/deforest/tests.rs b/crates/perry-transform/src/deforest/tests.rs index d2b46e79eb..fae1394f6e 100644 --- a/crates/perry-transform/src/deforest/tests.rs +++ b/crates/perry-transform/src/deforest/tests.rs @@ -23,6 +23,7 @@ fn detects_simple_producer() { Stmt::Expr(Expr::ArrayPush { array_id: 10, value: Box::new(Expr::Integer(1)), + field_writeback: None, }), Stmt::Return(Some(Expr::LocalGet(10))), ], @@ -104,6 +105,7 @@ fn synthetic_out_params_are_assigned_by_function_id() { first.body[1] = Stmt::Expr(Expr::ArrayPush { array_id: 20, value: Box::new(Expr::Integer(1)), + field_writeback: None, }); first.body[2] = Stmt::Return(Some(Expr::LocalGet(20))); @@ -869,6 +871,7 @@ fn the_recursive_fuse_inside_a_producer_body_counts() { object: Box::new(Expr::LocalGet(40)), index: Box::new(Expr::LocalGet(41)), }), + field_writeback: None, })], }, ); @@ -956,6 +959,7 @@ fn fuse_consumer(id: FuncId, producer: FuncId, base: LocalId) -> Function { object: Box::new(Expr::LocalGet(child)), index: Box::new(Expr::LocalGet(j)), }), + field_writeback: None, })], }, Stmt::Return(Some(Expr::Integer(0))), @@ -989,6 +993,7 @@ fn make_simple_producer() -> Function { Stmt::Expr(Expr::ArrayPush { array_id: 10, value: Box::new(Expr::Integer(1)), + field_writeback: None, }), Stmt::Return(Some(Expr::LocalGet(10))), ], diff --git a/crates/perry-transform/src/field_push_local_bind.rs b/crates/perry-transform/src/field_push_local_bind.rs index 3c6cd548f6..fdd8b335da 100644 --- a/crates/perry-transform/src/field_push_local_bind.rs +++ b/crates/perry-transform/src/field_push_local_bind.rs @@ -1,6 +1,6 @@ //! Bind `this.field.push(v)` to a local so the inline append lowering applies. //! -//! `arr.push(v)` on a LOCAL array lowers to `Expr::ArrayPush { array_id }`, +//! `arr.push(v)` on a LOCAL array lowers to `Expr::ArrayPush { array_id, .. }`, //! which codegen turns into an inline bump append: a header test, one store, //! and — when the value and the live header jointly prove it — none of the //! per-store GC bookkeeping calls. The same push through a class field @@ -19,10 +19,8 @@ //! into //! //! ```text -//! let __push_recv_old = this.f; -//! let __push_recv = __push_recv_old; -//! __push_recv.push(v); // Expr::ArrayPush -//! if (__push_recv !== __push_recv_old) this.f = __push_recv; +//! let __push_recv = this.f; +//! __push_recv.push(v); // Expr::ArrayPush { field_writeback: Some("f") } //! ``` //! //! which is, read for read and write for write, what the native lowering @@ -34,6 +32,19 @@ //! roots across the value's evaluation, so the head survives a collection //! there; a synthetic codegen slot would not. //! +//! The write-back is NOT expressed in HIR. An earlier shape of this pass +//! emitted `if (__push_recv !== __push_recv_old) this.f = __push_recv;`, +//! and that guard is dead: a growing append leaves the old head as a +//! forwarding stub to the new one, and JS equality sees through forwarding +//! (perry matches Node — `const a = []; let b = a; b.push(...); a === b` +//! stays `true`), so the field kept pointing at the stub and every later +//! `this.f.length` / `this.f[i]` walked it through the dynamic property +//! path (#8897: a 2.5× entity-cycle regression that decayed only as the +//! arrays stopped growing). Instead the `ArrayPush` node carries the field +//! name in `field_writeback`, and codegen compares the receiver local's +//! HANDLE BITS before and after the append — the one comparison that does +//! not see through forwarding — re-pointing `this.f` when they differ. +//! //! Admission is deliberately narrow: //! //! * the receiver is `this.f` where `f` is an instance FIELD of the enclosing @@ -46,7 +57,12 @@ //! where a field may not be initialised yet, and never a static method. use perry_hir::types::LocalId; use perry_hir::types::Type; -use perry_hir::{Class, CompareOp, Expr, Function, Module, Stmt}; +use perry_hir::{Class, Expr, Function, Module, Stmt}; + +/// Name of the receiver local one expansion binds. `perry-codegen`'s +/// tiny-method rule (`collectors/hot_callees.rs`) recognises an expansion by +/// this name so the two statements still count as the one the author wrote. +pub const FIELD_PUSH_RECEIVER_NAME: &str = "__push_recv"; use crate::closure_local_inline::nested_stmt_lists; @@ -108,45 +124,24 @@ fn process_stmts(stmts: &mut Vec, fields: &[(String, Type)], next_local_id .into_iter() .next() .expect("push_single carries one argument"); - let old_id = alloc_local(next_local_id); let recv_id = alloc_local(next_local_id); - let field_get = || Expr::PropertyGet { - object: Box::new(Expr::This), - property: field.clone(), - byte_offset: 0, - }; let rewritten = [ - Stmt::Let { - id: old_id, - name: "__push_recv_old".to_string(), - ty: ty.clone(), - mutable: false, - init: Some(field_get()), - }, Stmt::Let { id: recv_id, - name: "__push_recv".to_string(), + name: FIELD_PUSH_RECEIVER_NAME.to_string(), ty: ty.clone(), mutable: true, - init: Some(Expr::LocalGet(old_id)), + init: Some(Expr::PropertyGet { + object: Box::new(Expr::This), + property: field.clone(), + byte_offset: 0, + }), }, Stmt::Expr(Expr::ArrayPush { array_id: recv_id, value: Box::new(value), + field_writeback: Some(field), }), - Stmt::If { - condition: Expr::Compare { - op: CompareOp::Ne, - left: Box::new(Expr::LocalGet(recv_id)), - right: Box::new(Expr::LocalGet(old_id)), - }, - then_branch: vec![Stmt::Expr(Expr::PropertySet { - object: Box::new(Expr::This), - property: field.clone(), - value: Box::new(Expr::LocalGet(recv_id)), - })], - else_branch: None, - }, ]; let n = rewritten.len(); stmts.splice(i..i, rewritten); @@ -278,61 +273,56 @@ mod tests { } #[test] - fn a_field_push_statement_binds_a_local_and_writes_back_only_on_realloc() { + fn a_field_push_statement_binds_a_local_and_carries_the_field_writeback() { let mut m = module_with(class( vec![field("items", Type::Array(Box::new(Type::Number)))], vec![push_stmt("items")], )); run(&mut m); let body = &m.classes[0].methods[0].body; - assert_eq!(body.len(), 4, "{body:?}"); - let (old_id, recv_id) = match (&body[0], &body[1]) { - ( - Stmt::Let { - id: old, - init: Some(Expr::PropertyGet { property, .. }), - .. - }, - Stmt::Let { - id: recv, - init: Some(Expr::LocalGet(from)), - mutable: true, - .. - }, - ) => { + assert_eq!(body.len(), 2, "{body:?}"); + let recv_id = match &body[0] { + Stmt::Let { + id, + name, + init: + Some(Expr::PropertyGet { + object, property, .. + }), + mutable: true, + .. + } => { + assert_eq!(name, FIELD_PUSH_RECEIVER_NAME); + assert!(matches!(object.as_ref(), Expr::This)); assert_eq!(property, "items"); - assert_eq!(from, old); - (*old, *recv) + *id } - other => panic!("expected the two receiver lets, got {other:?}"), + other => panic!("expected the mutable receiver let, got {other:?}"), }; - assert!( - matches!(&body[2], Stmt::Expr(Expr::ArrayPush { array_id, .. }) if *array_id == recv_id), - "the push must target the mutable receiver local: {:?}", - body[2] - ); - match &body[3] { - Stmt::If { - condition: - Expr::Compare { - op: CompareOp::Ne, - left, - right, - }, - then_branch, - else_branch: None, - } => { - assert!(matches!(left.as_ref(), Expr::LocalGet(id) if *id == recv_id)); - assert!(matches!(right.as_ref(), Expr::LocalGet(id) if *id == old_id)); - assert!(matches!( - &then_branch[..], - [Stmt::Expr(Expr::PropertySet { property, value, .. })] - if property == "items" - && matches!(value.as_ref(), Expr::LocalGet(id) if *id == recv_id) - )); + match &body[1] { + Stmt::Expr(Expr::ArrayPush { + array_id, + field_writeback: Some(field), + .. + }) => { + assert_eq!( + *array_id, recv_id, + "the push must target the receiver local" + ); + assert_eq!( + field, "items", + "the write-back names the field the receiver came from" + ); } - other => panic!("expected the guarded write-back, got {other:?}"), + other => panic!("expected the field-writeback push, got {other:?}"), } + // No HIR-level guard: the `!==` compare an earlier shape emitted is + // dead after a growing append (#8897), so nothing but the two + // statements above may remain. + assert!( + !body.iter().any(|s| matches!(s, Stmt::If { .. })), + "the write-back must not be an HIR compare: {body:?}" + ); } #[test] diff --git a/crates/perry-transform/src/generator/per_iteration.rs b/crates/perry-transform/src/generator/per_iteration.rs index e51c144ef3..b809d371e6 100644 --- a/crates/perry-transform/src/generator/per_iteration.rs +++ b/crates/perry-transform/src/generator/per_iteration.rs @@ -852,7 +852,7 @@ pub(crate) fn collect_written_suspended_loop_captures(body: &[Stmt]) -> HashSet< /// Ids that appear in a reference form [`rewrite_written_captures_to_cells`] /// does NOT rewrite: array/set mutation intrinsics keyed on a bare `LocalId` -/// (`arr.push(x)` → `ArrayPush { array_id }`, `set.add(x)`, `arr.pop()`, …) and +/// (`arr.push(x)` → `ArrayPush { array_id, .. }`, `set.add(x)`, `arr.pop()`, …) and /// `with`-statement fallbacks. Turning such a binding into a cell would leave /// the intrinsic pointing at the one-element cell array instead of the value it /// holds, so these ids are excluded from the candidate set. diff --git a/crates/perry-transform/src/unroll/mod.rs b/crates/perry-transform/src/unroll/mod.rs index 8e99436547..45cd711e42 100644 --- a/crates/perry-transform/src/unroll/mod.rs +++ b/crates/perry-transform/src/unroll/mod.rs @@ -974,7 +974,9 @@ fn refresh_in_expr( lookup(remap, id); return; } - Expr::ArrayPush { array_id, value } => { + Expr::ArrayPush { + array_id, value, .. + } => { lookup(remap, array_id); refresh_in_expr(value, remap, next_id, next_func_id); return; @@ -1623,6 +1625,7 @@ mod tests { is_async: false, is_generator: false, }), + field_writeback: None, }), ]; let f = make_for(i, 0, 3, body, CompareOp::Lt); From eb5b6f22f3c02f8079269be23319db195484145a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 11:13:42 +0200 Subject: [PATCH 2/2] field-push write-back: require the field to still hold the captured head; count only complete expansions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on #8931: - The write-back arm re-reads `this.` (`apush.field.still_held`) and stores only when its bits equal the captured pre-push head. The receiver is read before the argument is evaluated, so an argument that assigns the field itself (`this.f.push(this.reset())`) must win over the repair — and now does; a collection that already rewrote the field to the moved array skips a redundant store the same way. - `hot_callees`' tiny-method rule counts an expansion only as the complete adjacent shape (`let __push_recv = this.f` + the `ArrayPush` on that id with the same field as its write-back), so an author's own local named `__push_recv` cannot shrink a method into the hot-allocation set. - e2e regression tests (`issue_8897_field_push_writeback.rs`): the issue's reproducer, the argument-reassigns-field case at 0/16/64 fills, and a frozen receiver — all node-identical output. Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ --- .../src/collectors/hot_callees.rs | 54 +++++++- crates/perry-codegen/src/expr/array_push.rs | 43 ++++-- .../src/expr/array_push_guard_tests.rs | 12 ++ .../tests/issue_8897_field_push_writeback.rs | 124 ++++++++++++++++++ 4 files changed, 221 insertions(+), 12 deletions(-) create mode 100644 crates/perry/tests/issue_8897_field_push_writeback.rs diff --git a/crates/perry-codegen/src/collectors/hot_callees.rs b/crates/perry-codegen/src/collectors/hot_callees.rs index 53551c4af4..7a826e593d 100644 --- a/crates/perry-codegen/src/collectors/hot_callees.rs +++ b/crates/perry-codegen/src/collectors/hot_callees.rs @@ -76,11 +76,36 @@ const TINY_METHOD_ALLOC_SITE_BUDGET: u32 = 8; const FIELD_PUSH_RECEIVER_NAME: &str = "__push_recv"; /// Statement count for the tiny-method rule: each field-push expansion -/// counts as the single statement it came from. +/// counts as the single statement it came from. An expansion is the COMPLETE +/// shape the pass emits — the receiver `let` immediately followed by the +/// `ArrayPush` on that local carrying the same field as its write-back — so +/// an author's own local that happens to be named `__push_recv` does not +/// shrink the count. fn tiny_method_stmt_count(body: &[Stmt]) -> usize { let expansions = body - .iter() - .filter(|stmt| matches!(stmt, Stmt::Let { name, .. } if name == FIELD_PUSH_RECEIVER_NAME)) + .windows(2) + .filter(|pair| { + matches!( + pair, + [ + Stmt::Let { + id, + name, + mutable: true, + init: Some(Expr::PropertyGet { object, property, .. }), + .. + }, + Stmt::Expr(Expr::ArrayPush { + array_id, + field_writeback: Some(field), + .. + }), + ] if name == FIELD_PUSH_RECEIVER_NAME + && array_id == id + && matches!(object.as_ref(), Expr::This) + && field == property + ) + }) .count(); body.len().saturating_sub(expansions) } @@ -935,6 +960,21 @@ mod recursion_participant_tests { *name = "old".to_string(); } module.classes.push(class_with_method(func(13, plain))); + // An author's own local named like the receiver, followed by an + // unrelated statement: no expansion, two statements. + let mut collision = expanded_field_push(401); + collision[1] = Stmt::Expr(new_expr()); + module.classes.push(class_with_method(func(14, collision))); + // The receiver `let` followed by a push WITHOUT a write-back target + // (an ordinary local push that merely shares the name): two. + let mut no_target = expanded_field_push(501); + if let Stmt::Expr(Expr::ArrayPush { + field_writeback, .. + }) = &mut no_target[1] + { + *field_writeback = None; + } + module.classes.push(class_with_method(func(15, no_target))); assert_eq!( tiny_method_stmt_count(&module.classes[0].methods[0].body), @@ -948,6 +988,14 @@ mod recursion_participant_tests { tiny_method_stmt_count(&module.classes[2].methods[0].body), 2 ); + assert_eq!( + tiny_method_stmt_count(&module.classes[3].methods[0].body), + 2 + ); + assert_eq!( + tiny_method_stmt_count(&module.classes[4].methods[0].body), + 2 + ); let hot = collect_alloc_hot_functions(&module); assert!( hot.contains(&11), diff --git a/crates/perry-codegen/src/expr/array_push.rs b/crates/perry-codegen/src/expr/array_push.rs index fd83af0a10..0c29c8a43c 100644 --- a/crates/perry-codegen/src/expr/array_push.rs +++ b/crates/perry-codegen/src/expr/array_push.rs @@ -677,13 +677,20 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, value_discarded: bool) -> Ok(result) } -/// The write-back half of [`lower`]: `if (bits(local) != before && -/// this is a plain object) this. = local`, as an ordinary -/// `PropertySet` lowering (class-field IC, barrier and layout note included) -/// behind two inline tests. The header test is what makes the repair -/// unobservable: it runs only on a `GC_TYPE_OBJECT` receiver with none of -/// [`FIELD_WRITEBACK_BLOCKING_FLAGS_I16`] set, i.e. a plain data-property -/// store of the very object the field already names. +/// The write-back half of [`lower`]: `if (bits(local) != before && this is +/// a plain object && bits(this.) == before) this. = local`, as +/// an ordinary `PropertySet` lowering (class-field IC, barrier and layout +/// note included) behind three inline tests, all off the hot path (the +/// first fails whenever the append did not re-allocate). The header test is +/// what makes the repair unobservable: it runs only on a `GC_TYPE_OBJECT` +/// receiver with none of [`FIELD_WRITEBACK_BLOCKING_FLAGS_I16`] set, i.e. a +/// plain data-property store. The field test is what makes it a REPAIR and +/// not a store: the receiver was read before the argument was evaluated +/// (`let __push_recv = this.f` precedes the push), so an argument that +/// assigns `this.f` itself — `this.f.push(this.reset())` — must win, and it +/// does, because the field then no longer holds the captured head. (A +/// collection that already rewrote the field to the moved array fails the +/// same test and skips a store that would have been redundant.) fn emit_field_push_writeback( ctx: &mut FnCtx<'_>, array_id: u32, @@ -694,9 +701,11 @@ fn emit_field_push_writeback( let this_box = lower_expr(ctx, &Expr::This)?; let deref_idx = ctx.new_block("apush.field.deref"); + let field_idx = ctx.new_block("apush.field.still_held"); let store_idx = ctx.new_block("apush.field.writeback"); let done_idx = ctx.new_block("apush.field.done"); let deref_label = ctx.block_label(deref_idx); + let field_label = ctx.block_label(field_idx); let store_label = ctx.block_label(store_idx); let done_label = ctx.block_label(done_idx); @@ -729,8 +738,24 @@ fn emit_field_push_writeback( let reserved = blk.load(I16, &res_ptr); let blocking = blk.and(I16, &reserved, FIELD_WRITEBACK_BLOCKING_FLAGS_I16); let plain = blk.icmp_eq(I16, &blocking, "0"); - let store_ok = blk.and(I1, &is_object, &plain); - blk.cond_br(&store_ok, &store_label, &done_label); + let plain_object = blk.and(I1, &is_object, &plain); + blk.cond_br(&plain_object, &field_label, &done_label); + } + + ctx.current_block = field_idx; + let field_box = lower_expr( + ctx, + &Expr::PropertyGet { + object: Box::new(Expr::This), + property: field.to_string(), + byte_offset: 0, + }, + )?; + { + let blk = ctx.block(); + let field_bits = blk.bitcast_double_to_i64(&field_box); + let still_held = blk.icmp_eq(I64, &field_bits, before_bits); + blk.cond_br(&still_held, &store_label, &done_label); } ctx.current_block = store_idx; diff --git a/crates/perry-codegen/src/expr/array_push_guard_tests.rs b/crates/perry-codegen/src/expr/array_push_guard_tests.rs index 3e3f7f86a5..50cc970fd9 100644 --- a/crates/perry-codegen/src/expr/array_push_guard_tests.rs +++ b/crates/perry-codegen/src/expr/array_push_guard_tests.rs @@ -581,6 +581,18 @@ fn a_field_push_writes_the_field_back_on_a_handle_bits_change_behind_a_plain_obj body.contains("class_field_set.fast") && body.contains("@js_class_field_set_fallback("), "the write-back arm must be the class-field store:\n{body}" ); + // Between the header gate and the store: `this.items` is re-read and its + // bits compared with the captured head, so an argument that assigned the + // field (`this.items.push(this.reset())`) keeps its assignment. + assert!( + body.contains("apush.field.still_held"), + "the field-still-held gate must exist:\n{body}" + ); + let bits_compares = body.matches("icmp eq i64 %").count(); + assert!( + bits_compares >= 2, + "both the local and the field are compared against the captured bits ({bits_compares}):\n{body}" + ); assert!( body.contains("icmp eq i64 %"), "the decision must be a handle-bits compare, not a JS equality:\n{body}" diff --git a/crates/perry/tests/issue_8897_field_push_writeback.rs b/crates/perry/tests/issue_8897_field_push_writeback.rs new file mode 100644 index 0000000000..58e3e04122 --- /dev/null +++ b/crates/perry/tests/issue_8897_field_push_writeback.rs @@ -0,0 +1,124 @@ +//! #8897: `this.f.push(v)` is expanded by `field_push_local_bind` into a +//! receiver local plus an inline `ArrayPush` that writes the field back when +//! the append re-allocated the head. The write-back must (1) actually happen +//! after a growing append — JS equality sees through the growth-forwarding +//! stub, so it is decided on handle bits — (2) never clobber an assignment +//! the push ARGUMENT made to the same field, and (3) never throw on a +//! receiver that cannot take a plain store. +use std::path::PathBuf; +use std::process::{Command, Output}; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(source: &str) -> Output { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.js"); + let output = dir.path().join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-auto-optimize") + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + Command::new(&output).output().expect("run compiled binary") +} + +fn stdout_of(run: &Output) -> String { + assert!( + run.status.success(), + "the program must exit cleanly\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +/// The issue's reproducer: a class whose field arrays grow past their initial +/// capacity, read back through `this.packed.length` / `this.packed[i]` on a +/// hot path. Output is what Node prints; the regression was a 2.5x slowdown +/// from every such read walking the forwarding stub through the dynamic +/// property path, which this test cannot time — but the class-field IC that +/// the write-back keeps warm is also what keeps the results correct after +/// the many re-allocations below. +#[test] +fn growing_field_pushes_keep_the_field_pointing_at_the_live_head() { + let run = compile_and_run( + r#" +class SparseSet { + packed = []; + sparse = []; + has(x) { return this.sparse[x] < this.packed.length && this.packed[this.sparse[x]] === x; } + add(x) { if (!this.has(x)) { this.sparse[x] = this.packed.length; this.packed.push(x); } } + remove(x) { if (this.has(x)) { const last = this.packed.pop(); if (x !== last) { this.sparse[last] = this.sparse[x]; this.packed[this.sparse[x]] = last; } } } +} +const rm = new SparseSet(); const other = new SparseSet(); other.packed = []; +for (let i = 0; i < 1000; i++) rm.add(i); +for (let i = 999; i >= 0; i--) rm.remove(i); +for (let i = 0; i < 1000; i++) rm.add(i); +let hits = 0; +for (let i = 0; i < 300000; i++) if (rm.has(i & 2047)) hits++; +console.log("hits", hits, other.packed.length, rm.packed.length); +"#, + ); + assert_eq!(stdout_of(&run), "hits 146992 0 1000\n"); +} + +/// `this.items.push(this.reset())`: the receiver is read BEFORE the argument +/// runs, so the push lands on the old array, and the argument's own +/// assignment to `this.items` must survive the write-back — even when the +/// push re-allocated the old array (it is filled to its initial capacity +/// first so the appending push grows it). +#[test] +fn an_argument_that_reassigns_the_field_wins_over_the_write_back() { + let run = compile_and_run( + r#" +class Buffer { + items = []; + old = null; + reset() { this.old = this.items; this.items = [9]; return 7; } + fill(n) { for (let i = 0; i < n; i++) this.items.push(i); } + add() { this.items.push(this.reset()); } +} +for (const n of [0, 16, 64]) { + const b = new Buffer(); + b.fill(n); + b.add(); + console.log(n, b.items.length, b.items[0], b.old.length, b.old[b.old.length - 1]); +} +"#, + ); + assert_eq!(stdout_of(&run), "0 1 9 1 7\n16 1 9 17 7\n64 1 9 65 7\n"); +} + +/// A frozen receiver cannot take the repair store; the append itself is a +/// mutation of the (unfrozen) array and must still succeed, and later reads +/// of the field must still see the grown array through the forwarding head +/// rather than throwing on the skipped write-back. +#[test] +fn a_frozen_receiver_keeps_the_append_and_skips_the_repair_without_throwing() { + let run = compile_and_run( + r#" +class Log { + lines = []; + constructor() { Object.freeze(this); } + add(v) { this.lines.push(v); } +} +const log = new Log(); +for (let i = 0; i < 40; i++) log.add(i); +console.log(log.lines.length, log.lines[39], Object.isFrozen(log)); +"#, + ); + assert_eq!(stdout_of(&run), "40 39 true\n"); +}