From e5beb5eaa49f64ab05a5d06e346e8fde42afb350 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Tue, 25 Aug 2026 01:14:00 +0200 Subject: [PATCH] perf(codegen): version packed loops over closure captures --- .../8786-closure-captured-packed-loops.md | 1 + crates/perry-codegen/src/expr/mod.rs | 20 + .../src/runtime_decls/strings.rs | 8 + crates/perry-codegen/src/stmt/let_stmt.rs | 1 + crates/perry-codegen/src/stmt/loops.rs | 5 + .../src/stmt/stable_packed_loop.rs | 573 +++++++++++++----- crates/perry-runtime/src/array/subclass.rs | 100 ++- ...issue_8773_closure_capture_packed_loops.rs | 337 ++++++++++ 8 files changed, 873 insertions(+), 172 deletions(-) create mode 100644 changelog.d/8786-closure-captured-packed-loops.md create mode 100644 crates/perry/tests/issue_8773_closure_capture_packed_loops.rs diff --git a/changelog.d/8786-closure-captured-packed-loops.md b/changelog.d/8786-closure-captured-packed-loops.md new file mode 100644 index 0000000000..efd86774a3 --- /dev/null +++ b/changelog.d/8786-closure-captured-packed-loops.md @@ -0,0 +1 @@ +Optimize immutable closure-captured packed Array and Array-subclass loops, including nested arrays derived from guarded indexed reads, while retaining generic side exits for rebinding, layout changes, and moving GC. diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index e9f48237eb..29b162b5b2 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -1611,6 +1611,22 @@ pub(crate) struct StablePackedLoopFact { pub array_local_id: u32, pub side_exit_label: String, pub descriptor: String, + /// Boxed bound passed to the runtime guard (`-1` requests live length). + pub bound: String, + /// Live-length versions must observe growth as well as shrink. The + /// iteration guard compares its refreshed bound with this admitted value + /// and side-exits when they differ. + pub admitted_bound: String, + pub live_length_bound: bool, + /// Captured receivers cannot keep a raw address across calls in the loop + /// body. They reload the closure slot and revalidate before the first + /// indexed effect of every iteration. + pub revalidate_each_iteration: bool, + /// A nested receiver derived from an outer guarded read may have pure + /// compiler temporaries before its first indexed use. Revalidate at that + /// use, after those temporaries, so none of their runtime loads can leave a + /// stale raw address. + pub revalidate_before_indexed_read: bool, pub live_receiver_handle: Option, /// Admission scanned the complete indexed range and proved every value is /// an untagged IEEE Number. This is requested only when the indexed value @@ -1619,6 +1635,10 @@ pub(crate) struct StablePackedLoopFact { /// Preheader-derived numeric storage bases. Admission proved the complete /// range is raw f64 and the call-free clone keeps these addresses stable. pub numeric_access: Option, + /// Immutable locals initialized from this loop's guarded direct indexed + /// read. They may seed a nested candidate only while this fast-loop fact + /// is active. + pub derived_locals: std::collections::HashSet, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index d0b622dab4..a7017e3c04 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -703,6 +703,14 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { I32, &[DOUBLE, DOUBLE, I32, PTR], ); + // #8773: same complete packed-loop admission, returning the validated + // live receiver address. Captured bindings may hold an array-growth + // forwarding stub and cannot be rewritten like a compiler-private local. + module.declare_function( + "js_packed_arraylike_loop_guard_live", + I64, + &[DOUBLE, DOUBLE, I32, PTR], + ); // Issue #957: tag-aware dynamic index write. Used by `Expr::IndexUpdate` // codegen to write back the incremented value without rebuilding the // IndexSet dispatch tree. Routes to `js_array_set_index_or_string` for diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 31f9fed412..120905880f 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -120,6 +120,7 @@ pub(crate) fn lower_let( } } if let Some(init_expr) = init { + super::stable_packed_loop::record_derived_local(ctx, id, init_expr, mutable); crate::expr::record_local_value_alias_for_write(ctx, id, init_expr); record_array_length_snapshot(ctx, id, init_expr); ctx.guarded_discriminant_aliases.remove(&id); diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index a952e0a96e..9ecca9f867 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -5412,6 +5412,11 @@ pub(super) fn lower_for_after_init_with_i32_bound( // Body block. ctx.current_block = body_idx; super::versioned_indexed_loop::emit_iteration_guard(ctx); + let loop_counter_id = match init { + Some(Stmt::Let { id, .. }) => Some(*id), + _ => None, + }; + super::stable_packed_loop::emit_iteration_guard(ctx, loop_counter_id)?; if let Some(cond) = condition { let mut guarded = crate::expr::guarded_buffer_indices_for_condition(ctx, cond, loop_proof_scope_id); diff --git a/crates/perry-codegen/src/stmt/stable_packed_loop.rs b/crates/perry-codegen/src/stmt/stable_packed_loop.rs index 460a861385..0a3bd01d5e 100644 --- a/crates/perry-codegen/src/stmt/stable_packed_loop.rs +++ b/crates/perry-codegen/src/stmt/stable_packed_loop.rs @@ -1,9 +1,9 @@ //! Guarded loop versions for counted Array and Array-subclass iteration. //! -//! A one-time runtime admission publishes scalar layout facts. The fast copy -//! is entered only after its emitted blocks are proven call-free, so its -//! preheader-cached receiver and storage bases stay valid for the whole copy. -//! Failed admission runs the unchanged generic loop from the current counter. +//! Runtime admission publishes scalar layout facts. Ordinary bindings use a +//! call-free clone whose preheader-cached receiver stays valid throughout; +//! immutable closure captures reload and revalidate at every iteration. A +//! failed admission resumes the unchanged generic loop at the current counter. use anyhow::Result; use perry_hir::{CompareOp, Expr, Stmt, UpdateOp}; @@ -23,6 +23,10 @@ struct Candidate { array_id: u32, bound: LoopBound, numeric_elements: bool, + capture_index: Option, + capture_uses_box: bool, + nested_derived: bool, + nested_requires_access_revalidation: bool, } fn target_below_numeric_operator( @@ -111,16 +115,30 @@ fn stmt_flags(stmt: &Stmt, array_id: u32, counter_id: u32) -> (bool, bool) { /// explicit user call. Later statements may allocate or invoke callbacks: the /// next iteration reloads the root and validates before using it again. fn body_has_safe_leading_read(body: &[Stmt], array_id: u32, counter_id: u32) -> bool { - let Some(first) = body.first() else { - return false; - }; - let (first_target, first_call) = stmt_flags(first, array_id, counter_id); - if !first_target || first_call { - return false; + for (index, stmt) in body.iter().enumerate() { + let (has_target, has_call) = stmt_flags(stmt, array_id, counter_id); + if has_target { + return !has_call + && !body[index + 1..] + .iter() + .any(|later| stmt_flags(later, array_id, counter_id).0); + } + // Compound indexed assignments are lowered into pure receiver/key + // temporaries before the source indexed read. Replaying these local + // copies on a side exit has no observable effect. Keep the admitted + // prefix deliberately narrow; property reads, calls, and writes stay + // generic. + if !matches!( + stmt, + Stmt::Let { + init: Some(Expr::LocalGet(_)), + .. + } + ) { + return false; + } } - !body[1..] - .iter() - .any(|stmt| stmt_flags(stmt, array_id, counter_id).0) + false } fn stmt_contains_break(stmt: &Stmt) -> bool { @@ -163,8 +181,33 @@ fn stmt_contains_break(stmt: &Stmt) -> bool { } } +fn record_capture_rejection(ctx: &mut FnCtx<'_>, array_id: u32, reason: &str) { + let lowered = LoweredValue::js_value("closure_capture_candidate".to_string()); + ctx.record_lowered_value_with_access_mode_and_facts( + "StablePackedArraylikeLoop", + Some(array_id), + "stable_packed_arraylike_capture_rejected", + &lowered, + Some(BoundsState::Unknown), + None, + Some(BufferAccessMode::DynamicFallback), + Some(MaterializationReason::RuntimeApi), + None, + None, + Vec::new(), + Vec::new(), + false, + false, + vec![ + "candidate_storage=closure_capture_slot".to_string(), + format!("rejection={reason}"), + "fallback=generic_counted_loop".to_string(), + ], + ); +} + fn match_candidate( - ctx: &FnCtx<'_>, + ctx: &mut FnCtx<'_>, init: Option<&Stmt>, condition: Option<&Expr>, update: Option<&Expr>, @@ -216,30 +259,71 @@ fn match_candidate( _ => return None, }; let receiver = Expr::LocalGet(array_id); - if ctx.reassigned_locals.contains(&array_id) - || ctx.closure_captures.contains_key(&array_id) - || (ctx.locals.contains_key(&array_id) && ctx.boxed_vars.contains(&array_id)) - || (!ctx.locals.contains_key(&array_id) && !ctx.module_globals.contains_key(&array_id)) - // TypedArrays have their own element-width-aware indexed lowering. - // Even though the runtime guard would decline their non-Array header, - // emitting the speculative clone can feed its numeric facts into - // function-wide native-representation selection. In particular a - // Uint32Array XOR then lost the required signed i32 canonicalization - // in the generic copy. Known TypedArrays are never valid candidates, - // so reject them before cloning rather than relying on the guard. - || crate::type_analysis::is_typed_array_expr(ctx, &receiver) - || super::loops::stmts_mutate_local(body, counter_id) - // A fast-loop `break` reaches that clone's exit block. Live-length - // versions use the same block to enter the generic continuation, so - // replaying the current iteration would duplicate preceding effects. - || body.iter().any(stmt_contains_break) - || !body_has_safe_leading_read(body, array_id, counter_id) - // Preserve the existing escape/materialization contract. A dynamic - // call before the loop may have exposed the binding to arbitrary JS; - // the broad #8690 guard must not resurrect a proof deliberately - // retired by that analysis. - || !super::loops::packed_loop_array_binding_is_eligible(ctx, array_id) - { + let capture_index = ctx.closure_captures.get(&array_id).copied(); + let derived_parent = ctx + .stable_packed_loop_facts + .iter() + .rev() + .find(|fact| fact.derived_locals.contains(&array_id)); + let nested_derived = derived_parent.is_some(); + let nested_requires_access_revalidation = derived_parent + .is_some_and(|fact| fact.revalidate_each_iteration || fact.revalidate_before_indexed_read); + let storage_is_available = capture_index.is_some() + || (ctx.locals.contains_key(&array_id) && !ctx.boxed_vars.contains(&array_id)) + || (!ctx.locals.contains_key(&array_id) && ctx.module_globals.contains_key(&array_id)); + let binding_is_eligible = if capture_index.is_some() || nested_derived { + // Capturing the binding is itself an identity exposure in the + // whole-function fact graph. That historical hazard is exactly what + // this version repairs: an immutable capture is reloaded and fully + // guarded at every iteration, so an alias may mutate the object only + // by making the next guard fail to the generic loop. Semantic + // rebinding remains represented in `reassigned_locals` and is rejected + // below; a compiler-only TDZ/hoisting box does not imply mutation. + !ctx.scalar_replaced_arrays.contains_key(&array_id) + } else { + super::loops::packed_loop_array_binding_is_eligible(ctx, array_id) + }; + let leading_read_is_first = body.first().is_some_and(|stmt| { + let (has_target, has_call) = stmt_flags(stmt, array_id, counter_id); + has_target && !has_call + }); + let rejection = if ctx.reassigned_locals.contains(&array_id) { + Some("reassigned_binding") + } else if !storage_is_available { + Some("unavailable_storage") + // TypedArrays have their own element-width-aware indexed lowering. Even + // though the runtime guard would decline their non-Array header, emitting + // the speculative clone can feed its numeric facts into function-wide + // native-representation selection. + } else if crate::type_analysis::is_typed_array_expr(ctx, &receiver) { + Some("known_typed_array") + } else if super::loops::stmts_mutate_local(body, counter_id) { + Some("counter_mutated_in_body") + // A fast-loop `break` reaches that clone's exit block. Live-length + // versions use the same block to enter the generic continuation, so + // replaying the current iteration would duplicate preceding effects. + } else if body.iter().any(stmt_contains_break) { + Some("break_replays_current_iteration") + } else if !body_has_safe_leading_read(body, array_id, counter_id) { + Some("indexed_read_not_safe_and_leading") + // LocalGet prefixes are replay-safe for a nested derived receiver, whose + // guard is emitted at the indexed read. A capture is guarded at iteration + // entry instead, and another captured LocalGet in such a prefix can run a + // GC helper before the cached address is consumed. Keep that shape on the + // generic path until entry guards can be placed after the prefix. + } else if capture_index.is_some() && !leading_read_is_first { + Some("capture_read_after_safepoint_capable_prefix") + // Preserve the existing escape/materialization contract for ordinary + // locals/globals. Captures use their separate guarded eligibility above. + } else if !binding_is_eligible { + Some("binding_not_eligible") + } else { + None + }; + if let Some(reason) = rejection { + if capture_index.is_some() { + record_capture_rejection(ctx, array_id, reason); + } return None; } Some(Candidate { @@ -247,9 +331,38 @@ fn match_candidate( array_id, bound, numeric_elements: leading_read_requires_numeric(body, array_id, counter_id), + capture_index, + capture_uses_box: capture_index.is_some() && ctx.boxed_vars.contains(&array_id), + nested_derived, + nested_requires_access_revalidation, }) } +/// Mark a local whose initializer is the exact direct indexed read admitted by +/// the active stable-packed fact. The mark lives on that fact, so it cannot +/// leak from the fast clone into the generic clone. +pub(super) fn record_derived_local(ctx: &mut FnCtx<'_>, id: u32, init: &Expr, mutable: bool) { + if mutable || ctx.reassigned_locals.contains(&id) { + return; + } + let Expr::IndexGet { object, index } = init else { + return; + }; + let (Expr::LocalGet(array_id), Expr::LocalGet(index_id)) = (object.as_ref(), index.as_ref()) + else { + return; + }; + let Some(fact) = ctx + .stable_packed_loop_facts + .iter_mut() + .rev() + .find(|fact| fact.array_local_id == *array_id && fact.counter_local_id == *index_id) + else { + return; + }; + fact.derived_locals.insert(id); +} + fn descriptor_word(ctx: &mut FnCtx<'_>, descriptor: &str, index: u64) -> String { let ptr = ctx .block() @@ -257,8 +370,137 @@ fn descriptor_word(ctx: &mut FnCtx<'_>, descriptor: &str, index: u64) -> String ctx.block().load(I64, &ptr) } -fn record_artifacts(ctx: &mut FnCtx<'_>, array_id: u32, receiver: &str) { +/// Derive raw numeric storage bases from a freshly validated receiver. This is +/// used once in ordinary call-free loops and at every iteration entry for a +/// closure capture, where a nested guard/callback may have moved the receiver +/// since the preceding iteration. +fn build_numeric_access( + ctx: &mut FnCtx<'_>, + descriptor: &str, + live_raw: &str, +) -> StablePackedNumericAccess { + let kind = descriptor_word(ctx, descriptor, 0); + let is_plain = ctx.block().icmp_eq(I64, &kind, "1"); + let plain_base = ctx.block().add(I64, live_raw, "8"); + + let element_base = descriptor_word(ctx, descriptor, 4); + let packed_bounds = descriptor_word(ctx, descriptor, 5); + let inline_bound = ctx.block().lshr(I64, &packed_bounds, "32"); + let has_inline = ctx.block().icmp_ult(I64, &element_base, &inline_bound); + let inline_span = ctx.block().sub(I64, &inline_bound, &element_base); + let object_inline_count = ctx.block().select(I1, &has_inline, I64, &inline_span, "0"); + let element_bytes = ctx.block().shl(I64, &element_base, "3"); + let object_header_size = + crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); + let inline_offset = ctx.block().add(I64, &object_header_size, &element_bytes); + let object_inline_base = ctx.block().add(I64, live_raw, &inline_offset); + + // Only Array-subclass objects own ObjectMeta. Keep the metadata load + // control-dependent so a plain Array never interprets element bits as a + // pointer. A missing spill is valid when the admitted bound fits inline. + let plain_setup_idx = ctx.new_block("stable_packed.setup.plain"); + let object_setup_idx = ctx.new_block("stable_packed.setup.object"); + let meta_setup_idx = ctx.new_block("stable_packed.setup.meta"); + let setup_merge_idx = ctx.new_block("stable_packed.setup.merge"); + let plain_setup_label = ctx.block_label(plain_setup_idx); + let object_setup_label = ctx.block_label(object_setup_idx); + let meta_setup_label = ctx.block_label(meta_setup_idx); + let setup_merge_label = ctx.block_label(setup_merge_idx); + ctx.block() + .cond_br(&is_plain, &plain_setup_label, &object_setup_label); + + ctx.current_block = plain_setup_idx; + ctx.block().br(&setup_merge_label); + + ctx.current_block = object_setup_idx; + let pointer_size = if crate::target_layout::target_is_ilp32(ctx.target_triple) { + 4 + } else { + 8 + }; + let meta_offset = (crate::target_layout::object_header_size_bytes(ctx.target_triple) + - pointer_size) + .to_string(); + let meta_addr = ctx.block().add(I64, live_raw, &meta_offset); + let meta_slot = ctx.block().inttoptr(I64, &meta_addr); + let meta_native = ctx + .block() + .load(if pointer_size == 4 { I32 } else { I64 }, &meta_slot); + let meta = if pointer_size == 4 { + ctx.block().zext(I32, &meta_native, I64) + } else { + meta_native + }; + let has_meta = ctx.block().icmp_ne(I64, &meta, "0"); + ctx.block() + .cond_br(&has_meta, &meta_setup_label, &setup_merge_label); + + ctx.current_block = meta_setup_idx; + let meta_ptr = ctx.block().inttoptr(I64, &meta); + let spill_slot = ctx.block().gep(I64, &meta_ptr, &[(I64, "4")]); + let spill = ctx.block().load(I64, &spill_slot); + ctx.block().br(&setup_merge_label); + + ctx.current_block = setup_merge_idx; + let spill = ctx.block().phi( + I64, + &[ + ("0", &plain_setup_label), + ("0", &object_setup_label), + (&spill, &meta_setup_label), + ], + ); + let has_spill = ctx.block().icmp_ne(I64, &spill, "0"); + let safe_spill = ctx.block().select(I1, &has_spill, I64, &spill, live_raw); + let spill_offset = ctx.block().add(I64, &element_bytes, "8"); + let object_spill_base = ctx.block().add(I64, &safe_spill, &spill_offset); + StablePackedNumericAccess { + is_plain, + plain_base, + object_inline_count, + object_inline_base, + object_spill_base, + } +} + +fn record_artifacts(ctx: &mut FnCtx<'_>, candidate: &Candidate, receiver: &str) { + let array_id = candidate.array_id; let lowered = LoweredValue::js_value(receiver.to_string()); + let mut selected_facts = vec![ + "loop_versioning=stable_packed_arraylike".to_string(), + "proof=preheader_scalar_layout".to_string(), + if candidate.capture_index.is_some() { + "candidate_storage=closure_capture_slot".to_string() + } else { + "candidate_storage=addressable_binding".to_string() + }, + if candidate.capture_index.is_some() { + "revalidation=each_iteration_capture_reload".to_string() + } else if candidate.nested_requires_access_revalidation { + "revalidation=before_nested_indexed_read".to_string() + } else { + "revalidation=none_call_free_clone".to_string() + }, + format!( + "guard_identity=stable_packed_arraylike:{}:{}", + candidate.array_id, candidate.counter_id + ), + "side_exit=current_index".to_string(), + ]; + if let Some(capture_index) = candidate.capture_index { + selected_facts.push(format!("capture_index={capture_index}")); + selected_facts.push(format!( + "capture_value_storage={}", + if candidate.capture_uses_box { + "compiler_box" + } else { + "inline_value" + } + )); + } + if candidate.nested_derived { + selected_facts.push("candidate_origin=guarded_outer_index_read".to_string()); + } ctx.record_lowered_value_with_access_mode_and_facts( "StablePackedArraylikeLoop", Some(array_id), @@ -276,12 +518,7 @@ fn record_artifacts(ctx: &mut FnCtx<'_>, array_id: u32, receiver: &str) { Vec::new(), false, false, - vec![ - "loop_versioning=stable_packed_arraylike".to_string(), - "proof=preheader_scalar_layout".to_string(), - "revalidation=none_call_free_clone".to_string(), - "side_exit=current_index".to_string(), - ], + selected_facts, ); ctx.record_lowered_value_with_access_mode_and_facts( "StablePackedArraylikeLoop", @@ -300,6 +537,10 @@ fn record_artifacts(ctx: &mut FnCtx<'_>, array_id: u32, receiver: &str) { false, vec![ "loop_versioning=stable_packed_arraylike_fallback".to_string(), + format!( + "fallback_identity=stable_packed_arraylike:{}:{}", + candidate.array_id, candidate.counter_id + ), "resume=current_index".to_string(), ], ); @@ -313,6 +554,51 @@ pub(crate) fn try_lower_index_get( let (Expr::LocalGet(array_id), Expr::LocalGet(counter_id)) = (object, index) else { return None; }; + let fact = ctx + .stable_packed_loop_facts + .iter() + .rev() + .find(|fact| fact.array_local_id == *array_id && fact.counter_local_id == *counter_id)? + .clone(); + if fact.revalidate_before_indexed_read { + let receiver_slot = ctx.locals.get(array_id)?.clone(); + let receiver = ctx.block().load(DOUBLE, &receiver_slot); + let live_raw = ctx.block().call( + I64, + "js_packed_arraylike_loop_guard_live", + &[ + (DOUBLE, &receiver), + (DOUBLE, &fact.bound), + (I32, if fact.numeric_elements { "1" } else { "0" }), + (PTR, &fact.descriptor), + ], + ); + let mut pass = ctx.block().icmp_ne(I64, &live_raw, "0"); + if fact.live_length_bound { + let refreshed_bound = descriptor_word(ctx, &fact.descriptor, 6); + let length_unchanged = ctx + .block() + .icmp_eq(I64, &refreshed_bound, &fact.admitted_bound); + pass = ctx.block().and(I1, &pass, &length_unchanged); + } + let continue_idx = ctx.new_block("stable_packed.indexed_read.derived_valid"); + let continue_label = ctx.block_label(continue_idx); + ctx.block() + .cond_br(&pass, &continue_label, &fact.side_exit_label); + ctx.current_block = continue_idx; + let numeric_access = fact + .numeric_elements + .then(|| build_numeric_access(ctx, &fact.descriptor, &live_raw)); + let active = ctx + .stable_packed_loop_facts + .iter_mut() + .rev() + .find(|active| { + active.array_local_id == *array_id && active.counter_local_id == *counter_id + })?; + active.live_receiver_handle = Some(live_raw); + active.numeric_access = numeric_access; + } let fact = ctx .stable_packed_loop_facts .iter() @@ -478,6 +764,58 @@ pub(crate) fn has_numeric_index_fact(ctx: &FnCtx<'_>, expr: &Expr) -> bool { }) } +/// Refresh a captured receiver at fast-iteration entry. The closure pointer is +/// reloaded through its GC root by ordinary `LocalGet` lowering, then the full +/// runtime admission rechecks identity, forwarding, layout, descriptors, +/// prototype state, packedness, and the admitted range. Only the returned live +/// address is published to direct indexed reads in this iteration. +pub(super) fn emit_iteration_guard( + ctx: &mut FnCtx<'_>, + loop_counter_id: Option, +) -> Result { + let Some(fact) = ctx.stable_packed_loop_facts.last().cloned() else { + return Ok(false); + }; + if !fact.revalidate_each_iteration || loop_counter_id != Some(fact.counter_local_id) { + return Ok(false); + } + + let receiver = crate::expr::lower_expr(ctx, &Expr::LocalGet(fact.array_local_id))?; + let live_raw = ctx.block().call( + I64, + "js_packed_arraylike_loop_guard_live", + &[ + (DOUBLE, &receiver), + (DOUBLE, &fact.bound), + (I32, if fact.numeric_elements { "1" } else { "0" }), + (PTR, &fact.descriptor), + ], + ); + let mut pass = ctx.block().icmp_ne(I64, &live_raw, "0"); + if fact.live_length_bound { + let refreshed_bound = descriptor_word(ctx, &fact.descriptor, 6); + let length_unchanged = ctx + .block() + .icmp_eq(I64, &refreshed_bound, &fact.admitted_bound); + pass = ctx.block().and(I1, &pass, &length_unchanged); + } + + let continue_idx = ctx.new_block("stable_packed.iteration.capture_valid"); + let continue_label = ctx.block_label(continue_idx); + ctx.block() + .cond_br(&pass, &continue_label, &fact.side_exit_label); + ctx.current_block = continue_idx; + + let numeric_access = fact + .numeric_elements + .then(|| build_numeric_access(ctx, &fact.descriptor, &live_raw)); + if let Some(active) = ctx.stable_packed_loop_facts.last_mut() { + active.live_receiver_handle = Some(live_raw); + active.numeric_access = numeric_access; + } + Ok(true) +} + pub(super) fn lower( ctx: &mut FnCtx<'_>, init: Option<&Stmt>, @@ -508,17 +846,23 @@ pub(super) fn lower( LoopBound::LiveLength => "-1.0".to_string(), }; let descriptor = ctx.func.alloca_entry_array(I64, 7); - let guard = ctx.block().call( - I32, - "js_packed_arraylike_loop_guard", - &[ - (DOUBLE, &receiver), - (DOUBLE, &bound_box), - (I32, if candidate.numeric_elements { "1" } else { "0" }), - (PTR, &descriptor), - ], - ); - let admitted = ctx.block().icmp_ne(I32, &guard, "0"); + let guard_args = [ + (DOUBLE, receiver.as_str()), + (DOUBLE, bound_box.as_str()), + (I32, if candidate.numeric_elements { "1" } else { "0" }), + (PTR, descriptor.as_str()), + ]; + let (admitted, admitted_live_raw) = if candidate.capture_index.is_some() { + let live_raw = ctx + .block() + .call(I64, "js_packed_arraylike_loop_guard_live", &guard_args); + (ctx.block().icmp_ne(I64, &live_raw, "0"), Some(live_raw)) + } else { + let guard = ctx + .block() + .call(I32, "js_packed_arraylike_loop_guard", &guard_args); + (ctx.block().icmp_ne(I32, &guard, "0"), None) + }; // Deliberately left unterminated until the emitted fast clone has been // scanned. The cached receiver below is safe only when no runtime call can // allocate, collect, or revoke an admitted layout while that clone runs. @@ -536,99 +880,21 @@ pub(super) fn lower( descriptor_word(ctx, &descriptor, 6) }; let bound_i32 = ctx.block().trunc(I64, &bound64, I32); - // Reload after the runtime admission call. Once the clone scan succeeds, - // this root cannot move until the clone returns because the clone contains - // no GC-unsafe call or allocation point. - let fast_receiver = crate::expr::lower_expr(ctx, &Expr::LocalGet(candidate.array_id))?; - let fast_bits = ctx.block().bitcast_double_to_i64(&fast_receiver); - let fast_raw = ctx - .block() - .and(I64, &fast_bits, crate::nanbox::POINTER_MASK_I64); + // A capture reload is itself a runtime call, so its admission returns the + // post-call live address. Ordinary addressable bindings retain the old + // guard/reload sequence; their reload is a plain load and their clone must + // still pass the call-free scan unless it has explicit access revalidation. + let fast_raw = if let Some(live_raw) = admitted_live_raw { + live_raw + } else { + let fast_receiver = crate::expr::lower_expr(ctx, &Expr::LocalGet(candidate.array_id))?; + let fast_bits = ctx.block().bitcast_double_to_i64(&fast_receiver); + ctx.block() + .and(I64, &fast_bits, crate::nanbox::POINTER_MASK_I64) + }; let fast_scan_start = ctx.func.num_blocks(); let numeric_access = if candidate.numeric_elements { - let kind = descriptor_word(ctx, &descriptor, 0); - let is_plain = ctx.block().icmp_eq(I64, &kind, "1"); - let plain_base = ctx.block().add(I64, &fast_raw, "8"); - - let element_base = descriptor_word(ctx, &descriptor, 4); - let packed_bounds = descriptor_word(ctx, &descriptor, 5); - let inline_bound = ctx.block().lshr(I64, &packed_bounds, "32"); - let has_inline = ctx.block().icmp_ult(I64, &element_base, &inline_bound); - let inline_span = ctx.block().sub(I64, &inline_bound, &element_base); - let object_inline_count = ctx.block().select(I1, &has_inline, I64, &inline_span, "0"); - let element_bytes = ctx.block().shl(I64, &element_base, "3"); - let object_header_size = - crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); - let inline_offset = ctx.block().add(I64, &object_header_size, &element_bytes); - let object_inline_base = ctx.block().add(I64, &fast_raw, &inline_offset); - - // Only Array-subclass objects own ObjectMeta. Keep the metadata load - // control-dependent so a plain Array never interprets element bits as - // a pointer. A missing spill is valid when the admitted bound fits in - // inline storage; the selected fallback address is then never loaded. - let plain_setup_idx = ctx.new_block("stable_packed.setup.plain"); - let object_setup_idx = ctx.new_block("stable_packed.setup.object"); - let meta_setup_idx = ctx.new_block("stable_packed.setup.meta"); - let setup_merge_idx = ctx.new_block("stable_packed.setup.merge"); - let plain_setup_label = ctx.block_label(plain_setup_idx); - let object_setup_label = ctx.block_label(object_setup_idx); - let meta_setup_label = ctx.block_label(meta_setup_idx); - let setup_merge_label = ctx.block_label(setup_merge_idx); - ctx.block() - .cond_br(&is_plain, &plain_setup_label, &object_setup_label); - - ctx.current_block = plain_setup_idx; - ctx.block().br(&setup_merge_label); - - ctx.current_block = object_setup_idx; - let pointer_size = if crate::target_layout::target_is_ilp32(ctx.target_triple) { - 4 - } else { - 8 - }; - let meta_offset = (crate::target_layout::object_header_size_bytes(ctx.target_triple) - - pointer_size) - .to_string(); - let meta_addr = ctx.block().add(I64, &fast_raw, &meta_offset); - let meta_slot = ctx.block().inttoptr(I64, &meta_addr); - let meta_native = ctx - .block() - .load(if pointer_size == 4 { I32 } else { I64 }, &meta_slot); - let meta = if pointer_size == 4 { - ctx.block().zext(I32, &meta_native, I64) - } else { - meta_native - }; - let has_meta = ctx.block().icmp_ne(I64, &meta, "0"); - ctx.block() - .cond_br(&has_meta, &meta_setup_label, &setup_merge_label); - - ctx.current_block = meta_setup_idx; - let meta_ptr = ctx.block().inttoptr(I64, &meta); - let spill_slot = ctx.block().gep(I64, &meta_ptr, &[(I64, "4")]); - let spill = ctx.block().load(I64, &spill_slot); - ctx.block().br(&setup_merge_label); - - ctx.current_block = setup_merge_idx; - let spill = ctx.block().phi( - I64, - &[ - ("0", &plain_setup_label), - ("0", &object_setup_label), - (&spill, &meta_setup_label), - ], - ); - let has_spill = ctx.block().icmp_ne(I64, &spill, "0"); - let safe_spill = ctx.block().select(I1, &has_spill, I64, &spill, &fast_raw); - let spill_offset = ctx.block().add(I64, &element_bytes, "8"); - let object_spill_base = ctx.block().add(I64, &safe_spill, &spill_offset); - Some(StablePackedNumericAccess { - is_plain, - plain_base, - object_inline_count, - object_inline_base, - object_spill_base, - }) + Some(build_numeric_access(ctx, &descriptor, &fast_raw)) } else { None }; @@ -637,9 +903,15 @@ pub(super) fn lower( array_local_id: candidate.array_id, side_exit_label: slow_pre_label.clone(), descriptor, + bound: bound_box, + admitted_bound: bound64, + live_length_bound: matches!(candidate.bound, LoopBound::LiveLength), + revalidate_each_iteration: candidate.capture_index.is_some(), + revalidate_before_indexed_read: candidate.nested_requires_access_revalidation, live_receiver_handle: Some(fast_raw), numeric_elements: candidate.numeric_elements, numeric_access, + derived_locals: std::collections::HashSet::new(), }); super::loops::lower_for_after_init_with_i32_bound( ctx, @@ -661,8 +933,11 @@ pub(super) fn lower( && (fast_scan_start..fast_scan_end) .all(|idx| !ctx.func.blocks()[idx].contains_gc_unsafe_call()); ctx.current_block = admission_idx; - if fast_clone_call_free { - record_artifacts(ctx, candidate.array_id, &receiver); + let fast_clone_is_safe = fast_clone_call_free + || candidate.capture_index.is_some() + || candidate.nested_requires_access_revalidation; + if fast_clone_is_safe { + record_artifacts(ctx, &candidate, &receiver); ctx.block() .cond_br(&admitted, &fast_pre_label, &slow_pre_label); } else { diff --git a/crates/perry-runtime/src/array/subclass.rs b/crates/perry-runtime/src/array/subclass.rs index 2650312f5f..d6b621d803 100644 --- a/crates/perry-runtime/src/array/subclass.rs +++ b/crates/perry-runtime/src/array/subclass.rs @@ -591,13 +591,12 @@ pub extern "C" fn js_packed_arraylike_index_get(receiver: f64, index: f64, cache /// dense_prefix|inline_bound<<32, bound)`. Kind 1 is an ArrayHeader and kind 2 /// is an ObjectHeader Array subclass. A zero return leaves every semantic case /// to the unchanged generic loop. -#[no_mangle] -pub extern "C" fn js_packed_arraylike_loop_guard( +fn packed_arraylike_loop_guard( receiver: f64, bound: f64, require_numeric: i32, out: *mut u64, -) -> i32 { +) -> Option<(i32, *const u8)> { let live_length_bound = bound == -1.0; if out.is_null() || !bound.is_finite() @@ -605,33 +604,54 @@ pub extern "C" fn js_packed_arraylike_loop_guard( || (!live_length_bound && bound.fract() != 0.0) || bound > 16_000_000.0 { - return 0; + return None; } let requested_bound = (!live_length_bound).then_some(bound as u32); let js = JSValue::from_bits(receiver.to_bits()); if !js.is_pointer() { - return 0; + return None; } - let raw = js.as_pointer::(); - let Some(header) = (unsafe { crate::value::addr_class::try_read_gc_header(raw as usize) }) + let source = js.as_pointer::(); + let Some(source_header) = + (unsafe { crate::value::addr_class::try_read_gc_header(source as usize) }) else { - return 0; + return None; }; - if header.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 { - return 0; - } + // Array growth preserves identity with a forwarding stub. Captured const + // slots cannot be canonicalized like compiler-private locals, so admit one + // validated edge and return the live address to codegen. A longer chain, + // a cross-brand target, or an unreadable target remains a generic-loop + // side exit. Moving GC normally rewrites closure slots, but accepting the + // same representation here also makes forced-evacuation entry fail-safe. + let raw = if source_header.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 { + if source_header.obj_type != crate::gc::GC_TYPE_ARRAY { + return None; + } + let target = unsafe { crate::gc::forwarding_address(source_header) }; + let target_header = + unsafe { crate::value::addr_class::try_read_gc_header(target as usize) }?; + if target_header.obj_type != crate::gc::GC_TYPE_ARRAY + || target_header.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 + { + return None; + } + target + } else { + source + }; + let header = unsafe { crate::value::addr_class::try_read_gc_header(raw as usize) }?; if header.obj_type == crate::gc::GC_TYPE_ARRAY { if header._reserved & crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS != 0 || super::PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED.load(Ordering::Relaxed) != 0 { - return 0; + return None; } let array = raw.cast::(); let (length, capacity) = unsafe { ((*array).length, (*array).capacity) }; let bound = requested_bound.unwrap_or(length); if bound > length || length > capacity || capacity > 16_000_000 { - return 0; + return None; } if require_numeric != 0 { // The raw-f64 invariant is an O(1) GcHeader bit after its first @@ -639,7 +659,7 @@ pub extern "C" fn js_packed_arraylike_loop_guard( // clears it. Reuse that representation proof instead of walking // the full range on every invocation of the surrounding scan(). if !unsafe { super::header::ensure_array_numeric_raw_f64(array as *mut ArrayHeader) } { - return 0; + return None; } } let gc_word = unsafe { ptr::read_unaligned((raw as *const u8).sub(8).cast::()) }; @@ -653,28 +673,29 @@ pub extern "C" fn js_packed_arraylike_loop_guard( out.add(5).write(0); out.add(6).write(u64::from(bound)); } - return 1; + return Some((1, raw)); } if header.obj_type != crate::gc::GC_TYPE_OBJECT { - return 0; + return None; } - let Some((object, layout)) = dense_layout_for_value(receiver) else { - return 0; + let live_receiver = f64::from_bits(crate::value::js_nanbox_pointer(raw as i64).to_bits()); + let Some((object, layout)) = dense_layout_for_value(live_receiver) else { + return None; }; if !crate::object::object_spill_enabled() || layout.length_slot >= layout.live_inline_slots { - return 0; + return None; } let Some(length) = nonnegative_u32_length(layout_length_value(object, layout)) else { - return 0; + return None; }; let bound = requested_bound.unwrap_or(length); if bound > length || bound > layout.dense_prefix_len || length > 16_000_000 { - return 0; + return None; } if require_numeric != 0 { if !unsafe { ensure_subclass_numeric_prefix(object, layout, bound) } { - return 0; + return None; } } let gc_word = unsafe { ptr::read_unaligned((raw as *const u8).sub(8).cast::()) }; @@ -690,7 +711,35 @@ pub extern "C" fn js_packed_arraylike_loop_guard( ); out.add(6).write(u64::from(bound)); } - 2 + Some((2, raw)) +} + +#[no_mangle] +pub extern "C" fn js_packed_arraylike_loop_guard( + receiver: f64, + bound: f64, + require_numeric: i32, + out: *mut u64, +) -> i32 { + packed_arraylike_loop_guard(receiver, bound, require_numeric, out) + .map(|(kind, _)| kind) + .unwrap_or(0) +} + +/// #8773 capture-safe packed-loop admission. In addition to filling the seven +/// scalar descriptor words, return the live receiver user address. The caller +/// consumes it before the next safepoint and reloads/revalidates on the next +/// iteration; the returned address is never stored as a GC root. +#[no_mangle] +pub extern "C" fn js_packed_arraylike_loop_guard_live( + receiver: f64, + bound: f64, + require_numeric: i32, + out: *mut u64, +) -> i64 { + packed_arraylike_loop_guard(receiver, bound, require_numeric, out) + .map(|(_, raw)| raw as i64) + .unwrap_or(0) } #[cfg(feature = "keepalive-anchors")] @@ -698,6 +747,11 @@ pub extern "C" fn js_packed_arraylike_loop_guard( static KEEP_JS_PACKED_ARRAYLIKE_LOOP_GUARD: extern "C" fn(f64, f64, i32, *mut u64) -> i32 = js_packed_arraylike_loop_guard; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_PACKED_ARRAYLIKE_LOOP_GUARD_LIVE: extern "C" fn(f64, f64, i32, *mut u64) -> i64 = + js_packed_arraylike_loop_guard_live; + #[cfg(feature = "keepalive-anchors")] #[used] static KEEP_JS_PACKED_ARRAYLIKE_INDEX_GET: extern "C" fn(f64, f64, *mut u64) -> f64 = diff --git a/crates/perry/tests/issue_8773_closure_capture_packed_loops.rs b/crates/perry/tests/issue_8773_closure_capture_packed_loops.rs new file mode 100644 index 0000000000..23bfdac554 --- /dev/null +++ b/crates/perry/tests/issue_8773_closure_capture_packed_loops.rs @@ -0,0 +1,337 @@ +//! Regression coverage for #8773: immutable closure-captured packed Arrays and +//! Array subclasses, including an inner array derived from the guarded outer +//! indexed read, receive direct-load fast loop versions with generic side exits. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn runtime_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("target") + .join(if cfg!(debug_assertions) { + "debug" + } else { + "release" + }) +} + +fn compile(dir: &Path, source: &str, retain_artifacts: bool) -> (PathBuf, String) { + let entry = dir.join("main.ts"); + let output = dir.join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + + let mut command = Command::new(perry_bin()); + command + .current_dir(dir) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .arg("--no-auto-optimize") + .env("PERRY_RUNTIME_DIR", runtime_dir()); + if retain_artifacts { + command + .env("PERRY_LLVM_KEEP_IR", "1") + .env("PERRY_NATIVE_REPS", "1") + .env("PERRY_NATIVE_REPS_DIR", dir.join("native-reps")); + } + let compiled = command.output().expect("run perry compile"); + assert!( + compiled.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compiled.stdout), + String::from_utf8_lossy(&compiled.stderr) + ); + ( + output, + String::from_utf8_lossy(&compiled.stderr).into_owned(), + ) +} + +fn run(binary: &Path, dir: &Path, moving_gc: bool) -> Output { + let mut command = Command::new(binary); + command.current_dir(dir); + if moving_gc { + command + .env("PERRY_GC_FORCE_EVACUATE", "1") + .env("PERRY_GC_VERIFY_EVACUATION", "1"); + } + command.output().expect("run compiled fixture") +} + +fn assert_output(output: &Output, expected: &str, moving_gc: bool) { + assert!( + output.status.success(), + "fixture failed with moving_gc={moving_gc}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), expected); +} + +fn named_blocks(ir: &str, prefixes: &[&str]) -> String { + let mut selected = false; + let mut result = String::new(); + for line in ir.lines() { + if !line.starts_with([' ', '\t']) && line.contains(':') { + selected = prefixes.iter().any(|prefix| line.contains(prefix)); + } + if selected { + result.push_str(line); + result.push('\n'); + } + } + result +} + +#[test] +fn nested_closure_capture_uses_live_guards_and_direct_fast_reads() { + let dir = tempfile::tempdir().expect("tempdir"); + let source = r#" +class Query extends Array {} +class Archetype extends Array {} + +function setup(entityCount: number) { + const query = new Query(); + const archetype = new Archetype(); + for (let i = 0; i < entityCount; i++) archetype.push(i); + query.push(archetype); + const values = new Uint32Array(entityCount); + + function system() { + for (let i = 0, length = query.length; i < length; i++) { + const current = query[i]; + for (let j = 0, length = current.length; j < length; j++) { + values[current[j]] += 1; + } + } + } + + return () => { + system(); + return values[0]; + }; +} + +const run = setup(1_000); +let checksum = 0; +for (let i = 0; i < 2_000; i++) checksum = run(); +console.log(checksum); +"#; + let (binary, stderr) = compile(dir.path(), source, true); + for moving_gc in [false, true] { + assert_output(&run(&binary, dir.path(), moving_gc), "2000\n", moving_gc); + } + + let ir_path = stderr + .lines() + .find_map(|line| line.split("kept LLVM IR: ").nth(1)) + .map(str::trim) + .map(PathBuf::from) + .unwrap_or_else(|| panic!("PERRY_LLVM_KEEP_IR did not report an IR path\n{stderr}")); + let ir = std::fs::read_to_string(ir_path).expect("read kept LLVM IR"); + let artifact_text = std::fs::read_dir(dir.path().join("native-reps")) + .expect("read native-reps directory") + .map(|entry| { + std::fs::read_to_string(entry.expect("native-reps entry").path()) + .expect("read native-reps artifact") + }) + .collect::(); + let stable_diagnostics = artifact_text + .lines() + .filter(|line| { + line.contains("stable_packed") + || line.contains("candidate_") + || line.contains("capture_") + || line.contains("rejection=") + }) + .collect::>() + .join("\n"); + let fast_preheaders = ir + .lines() + .filter(|line| line.starts_with("stable_packed.loop.fast.preheader") && line.ends_with(':')) + .count(); + assert!( + fast_preheaders >= 2, + "both captured outer and nested-derived inner loops need fast versions\n{stable_diagnostics}" + ); + assert!(ir.contains("stable_packed.iteration.capture_valid")); + assert!(ir.contains("call i64 @js_packed_arraylike_loop_guard_live(")); + + let fast_blocks = named_blocks(&ir, &["stable_packed", "for.stable_packed_fast"]); + assert!( + fast_blocks.contains("load double"), + "fast versions must contain direct element loads\n{fast_blocks}" + ); + assert!( + !fast_blocks.contains("js_packed_arraylike_index_get") + && !fast_blocks.contains("js_object_get_index_polymorphic"), + "fast versions must not retain indexed-read helpers\n{fast_blocks}" + ); + assert!( + ir.contains("js_packed_arraylike_index_get"), + "the unchanged generic fallback must remain in the function" + ); + + for required in [ + "candidate_storage=closure_capture_slot", + "revalidation=each_iteration_capture_reload", + "candidate_origin=guarded_outer_index_read", + "guard_identity=stable_packed_arraylike:", + "fallback_identity=stable_packed_arraylike:", + ] { + assert!( + artifact_text.contains(required), + "lowering explanation must identify `{required}`\n{stable_diagnostics}" + ); + } +} + +#[test] +fn captured_negative_shapes_preserve_generic_semantics() { + let dir = tempfile::tempdir().expect("tempdir"); + let source = r#" +class Query extends Array {} +class Archetype extends Array {} + +function make(source: any) { + const query = source; + return () => { + let text = ""; + for (let i = 0, length = query.length; i < length; i++) { + const current = query[i]; + for (let j = 0, length = current.length; j < length; j++) { + text += current[j] + ","; + } + } + return text; + }; +} + +function mutableCapture() { + let query: any = [[1, 2]]; + const scan = () => { + let sum = 0; + for (let i = 0, length = query.length; i < length; i++) { + const current = query[i]; + for (let j = 0, length = current.length; j < length; j++) sum += current[j]; + } + return sum; + }; + query = [[7, 8]]; + return scan; +} + +function prefixedCapture() { + const query = [0, 1]; + const values = new Uint32Array(2); + return () => { + for (let i = 0, length = query.length; i < length; i++) values[query[i]] += 1; + return values[0] + values[1]; + }; +} + +const dense: any = new Query(); +const row: any = new Archetype(); +row.push(1); row.push(2); row.push(3); dense.push(row); +console.log("dense=" + make(dense)()); + +const hole: any[] = [[4, 5, 6]]; +delete hole[0][1]; +console.log("hole=" + make(hole)()); + +const accessor: any[] = [[7, 8]]; +Object.defineProperty(accessor[0], "1", { get() { return 41; } }); +console.log("accessor=" + make(accessor)()); + +const proxy = new Proxy([[9, 10]], { + get(target: any, key: any) { return Reflect.get(target, key); } +}); +console.log("proxy=" + make(proxy)()); + +const resized: any[] = [[11, 12], [13]]; +const resizedScan = make(resized); +resized.push([14, 15]); +resized.length = 2; +console.log("resized=" + resizedScan()); + +const grown: any[] = [[16]]; +const grownScan = make(grown); +const grownAlias = grown; +grownAlias.push([17, 18]); +console.log("grown=" + grownScan()); + +const shrunk: any[] = [[19], [20, 21]]; +const shrunkScan = make(shrunk); +shrunk.length = 1; +console.log("shrunk=" + shrunkScan()); + +console.log("rebound=" + mutableCapture()()); +console.log("prefixed=" + prefixedCapture()()); + +const moved: any[] = [[22, 23, 24]]; +const movedScan = make(moved); +gc(); +console.log("moved=" + movedScan()); + +class PrototypeRow extends Array {} +const prototypeQuery: any = new Query(); +const prototypeRow: any = new PrototypeRow(); +prototypeRow.length = 1; +Object.defineProperty(PrototypeRow.prototype, "0", { get() { return 25; } }); +prototypeQuery.push(prototypeRow); +console.log("prototype=" + make(prototypeQuery)()); +"#; + let (binary, _) = compile(dir.path(), source, false); + let expected = "dense=1,2,3,\n\ + hole=4,undefined,6,\n\ + accessor=7,41,\n\ + proxy=9,10,\n\ + resized=11,12,13,\n\ + grown=16,17,18,\n\ + shrunk=19,\n\ + rebound=15\n\ + prefixed=2\n\ + moved=22,23,24,\n\ + prototype=25,\n"; + for moving_gc in [false, true] { + assert_output(&run(&binary, dir.path(), moving_gc), expected, moving_gc); + } +} + +#[test] +fn captured_accessor_exception_remains_observable() { + let dir = tempfile::tempdir().expect("tempdir"); + let source = r#" +function make() { + const query: any[] = [[16]]; + Object.defineProperty(query[0], "0", { get() { throw new Error("capture-getter"); } }); + return () => { + for (let i = 0, length = query.length; i < length; i++) { + const current = query[i]; + for (let j = 0, length = current.length; j < length; j++) console.log(current[j]); + } + }; +} +make()(); +"#; + let (binary, _) = compile(dir.path(), source, false); + for moving_gc in [false, true] { + let output = run(&binary, dir.path(), moving_gc); + assert!( + !output.status.success(), + "throwing getter unexpectedly succeeded with moving_gc={moving_gc}" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("capture-getter"), + "uncaught exception lost getter identity with moving_gc={moving_gc}:\n{stderr}" + ); + } +}