From b036afa3052a499b761d52311619c5f2711b3704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 8 Jul 2026 16:11:03 +0200 Subject: [PATCH 1/9] =?UTF-8?q?feat(gc):=20Phase=201=20=E2=80=94=20moving?= =?UTF-8?q?=20(copying)=20minor=20at=20the=20event-loop=20safepoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of the "one great GC" project: make Perry's already-built precise/generational/MOVING minor actually run, by triggering it at a precise-root safepoint instead of at arbitrary allocation points. Background: the copying minor (gc_collect_minor_copying_fast_path) is fully built but never runs today. The nursery-churn arm forces a conservative native-stack scan at the alloc point (a mid-construction value may live only in a register there), which makes the copying minor ineligible (CopiedMinorEligibility::evaluate -> ConservativeStack) so the non-moving minor runs. Everything else the moving minor needs is already satisfied: barriers on, zero copy-only scanners in production, a shadow stack that binds each live local to its real alloca so copied-minor GC can rewrite roots. This commit adds gc_safepoint_moving_minor(), called from the outermost microtask-pump boundary (js_promise_run_microtasks, depth==1). At that point the JS stack has fully unwound: no live register temporaries, every live heap value is a named local on the shadow stack or a registered root. So the copying minor is eligible with PRECISE, rewritable roots and no force_full_scan — it MOVES (compacting, O(survivors), no sweep). Trigger detection (ArenaBytes/MallocCount) and re-baseline mirror the nursery-churn arm; OldReclaim stays on its existing full-sweep path. Purely additive and gated behind PERRY_GC_MOVING_SAFEPOINT (default off) while the moving path is validated; the alloc-point fallback is untouched. Programs that yield to the event loop (servers especially — the RSS-sensitive case) get compacting young collection at safepoints. --- crates/perry-runtime/src/gc/policy.rs | 57 +++++++++++++++++++ .../perry-runtime/src/promise/microtasks.rs | 13 +++++ 2 files changed, 70 insertions(+) diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index f9043ec648..88fd298474 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -375,6 +375,21 @@ pub(crate) fn gc_note_external_side_free(bytes: usize) { } #[inline] +/// Phase 1+ of the moving-GC project (see the `project_gc_one_great_moving_gc` +/// design): gate the copying (moving) minor that runs at precise-root +/// safepoints. Default OFF while the moving path is validated; flipped on +/// (with `PERRY_GC_MOVING_SAFEPOINT=0` as the escape hatch) once moving is the +/// permanent default. +pub(crate) fn gc_moving_safepoint_enabled() -> bool { + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + matches!( + std::env::var("PERRY_GC_MOVING_SAFEPOINT").as_deref(), + Ok("1") | Ok("on") | Ok("true") + ) + }) +} + pub(super) fn gc_trace_enabled() -> bool { #[cfg(test)] if GC_TRACE_TEST_FORCE.with(Cell::get) { @@ -1303,6 +1318,48 @@ fn gc_budgeted_due_trigger() -> Option { None } +/// Phase 1 of the moving-GC project: run a copying (moving) minor at a +/// precise-root safepoint — the outermost microtask-pump boundary, where the +/// JS stack has fully unwound so no live heap pointer sits in an unspilled +/// register. Unlike the alloc-point nursery-churn arm, NO `force_full_scan` is +/// taken: `conservative_stack_scan_decision()` stays `SkipDisabled`, so the +/// copying minor is eligible with precise, rewritable roots and actually MOVES +/// (compacting, O(survivors), no sweep) instead of falling back to the +/// non-moving minor. Trigger detection + re-baseline mirror the nursery-churn +/// arm; this is purely additive (the alloc-point fallback is untouched) and +/// gated by `gc_moving_safepoint_enabled` (default off). +pub(super) fn gc_safepoint_moving_minor() { + // Same start guards the budgeted collector uses, minus the (here + // irrelevant) scanner block: never collect mid-allocation, inside a + // runtime handle scope, in an unsafe FFI zone, or during a budgeted cycle. + if GC_FLAGS.with(|f| f.get()) & (GC_FLAG_IN_ALLOC | GC_FLAG_SUPPRESSED) != 0 + || gc_blocked_by_unsafe_zone() + || GC_ROOT_LOCK_DEPTH.with(|depth| depth.get() != 0) + || gc_budgeted_cycle_active() + { + return; + } + // Only nursery-pressure triggers take the moving minor here; OldReclaim + // stays on its existing full mark-sweep path. + let kind = match gc_budgeted_due_trigger() { + Some(BudgetedGcTrigger::ArenaBytes) => GcTriggerKind::ArenaBytes, + Some(BudgetedGcTrigger::MallocCount) => GcTriggerKind::MallocCount, + _ => return, + }; + let pre_in_use = crate::arena::arena_in_use_bytes(); + let pre_malloc_count = malloc_object_count(); + // No `force_full_scan`: roots are precise at this safepoint. + let outcome = super::gc_collect_minor_with_trigger(GcTriggerSnapshot::capture(kind)); + match kind { + GcTriggerKind::MallocCount => { + gc_finish_malloc_trigger_collection(pre_malloc_count, outcome); + } + _ => { + gc_finish_arena_trigger_collection(pre_in_use, outcome); + } + } +} + struct BudgetedGcStepGuard; impl BudgetedGcStepGuard { diff --git a/crates/perry-runtime/src/promise/microtasks.rs b/crates/perry-runtime/src/promise/microtasks.rs index 0d69c28b84..18d4259bb3 100644 --- a/crates/perry-runtime/src/promise/microtasks.rs +++ b/crates/perry-runtime/src/promise/microtasks.rs @@ -780,6 +780,19 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { let _ = crate::gc::gc_runtime_safepoint(); + // Phase 1 of the moving-GC project (see project_gc_one_great_moving_gc): at + // the OUTERMOST microtask-pump boundary the JS stack has fully unwound, so + // there are no live register temporaries and the copying (moving) minor runs + // with precise, rewritable roots — no forced conservative scan. Run it when + // nursery pressure is due so programs that yield to the event loop get + // compacting, O(survivors) young collection instead of the non-moving + // alloc-point fallback. Gated (default off); additive. + if crate::gc::gc_moving_safepoint_enabled() + && MICROTASK_RUN_DEPTH.with(|depth| depth.get()) == 1 + { + crate::gc::gc_safepoint_moving_minor(); + } + MICROTASK_RUN_DEPTH.with(|depth| { depth.set(depth.get().saturating_sub(1)); }); From 77238be0637911709afbfa7ca4e7b8fe66100d6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 8 Jul 2026 17:07:12 +0200 Subject: [PATCH 2/9] feat(gc): [gc-copy-minor] PERRY_GC_DIAG line for the copying minor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds observability for the moving (copying) young-gen minor: under PERRY_GC_DIAG, each attempt logs whether it was eligible or the fallback reason (barriers_inactive / conservative_stack / copy_only_roots / pinned_young_* etc.), and each successful run logs copied_objects, copied_bytes, promoted_objects, freed_bytes. Without this the copying minor was invisible — PERRY_GC_DIAG only showed the sweep/evac-policy paths, so there was no way to tell whether a nursery collection actually moved survivors or fell back to the non-moving minor. Needed to validate the safepoint-triggered moving minor (it revealed that alloc-point minors fall back with conservative_stack by design while the event-loop-safepoint minor runs eligible and copies survivors). --- crates/perry-runtime/src/gc/copying.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/perry-runtime/src/gc/copying.rs b/crates/perry-runtime/src/gc/copying.rs index 78ff60ff92..19c0e72d05 100644 --- a/crates/perry-runtime/src/gc/copying.rs +++ b/crates/perry-runtime/src/gc/copying.rs @@ -903,6 +903,23 @@ pub(super) fn gc_collect_minor_copying_fast_path_with_eligibility( trace.root_sources.native_stack_fallback.scanned = matches!(decision, ConservativeStackScanDecision::Scan); } + if std::env::var_os("PERRY_GC_DIAG").is_some() { + let reason = match eligibility.fallback_reason { + CopiedMinorFallbackReason::None => "none", + CopiedMinorFallbackReason::NotAttempted => "not_attempted", + CopiedMinorFallbackReason::BarriersInactive => "barriers_inactive", + CopiedMinorFallbackReason::ConservativeStack => "conservative_stack", + CopiedMinorFallbackReason::CopyOnlyRoots => "copy_only_roots", + CopiedMinorFallbackReason::MallocRegistryUnavailable => "malloc_registry_unavailable", + CopiedMinorFallbackReason::PinnedYoungRoot => "pinned_young_root", + CopiedMinorFallbackReason::PinnedYoungDirtySlot => "pinned_young_dirty_slot", + CopiedMinorFallbackReason::PinnedYoungTransitive => "pinned_young_transitive", + }; + eprintln!( + "[gc-copy-minor] eligible={} fallback={}", + eligibility.eligible, reason + ); + } if !eligibility.eligible { return None; } @@ -1111,6 +1128,15 @@ pub(super) fn gc_collect_minor_copying_fast_path_with_eligibility( trace.capture_layout_scans(); } maybe_schedule_old_reclaim_after_copied_minor(); + if std::env::var_os("PERRY_GC_DIAG").is_some() { + eprintln!( + "[gc-copy-minor] ran copied_objects={} copied_bytes={} promoted_objects={} freed_bytes={}", + collector.stats.copied_objects, + collector.stats.copied_bytes, + collector.stats.promoted_objects, + freed_bytes + ); + } Some(CopiedMinorFastPathOutcome { freed_bytes, malloc_swept: malloc_sweep_due, From 7c2256e07cbd8716c780c174be6717a7a53e6da0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 8 Jul 2026 17:23:33 +0200 Subject: [PATCH 3/9] =?UTF-8?q?fix(gc):=20defensive=20guard=20=E2=80=94=20?= =?UTF-8?q?never=20memmove=20a=20young=20object=20with=20an=20out-of-range?= =?UTF-8?q?=20size?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardening for the copying minor (not a full fix; see the copying-minor relocation issue). A genuine young/survivor object is always small — large objects are allocated old-gen/malloc, never in the copying nursery — so a "young" object classified with a size below the header size or above a nursery block is a corrupt / mis-classified header (e.g. an off-heap typed-array pointer whose preceding bytes coincidentally pass plausible_gc_header). move_young previously trusted (*header).size unconditionally and could drive a wild out-of-bounds std::ptr::copy_nonoverlapping -> SIGSEGV. This refuses to relocate such an object (leaves it in place, which is correct for a real off-heap object kept live by its own side-table) and surfaces it under PERRY_GC_DIAG as [gc-move-guard]. It turns undefined behavior from a corrupt header into a no-op; it does NOT catch a plausible-but-wrong *small* size, so the root classification fix is still required. --- crates/perry-runtime/src/gc/copying.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/perry-runtime/src/gc/copying.rs b/crates/perry-runtime/src/gc/copying.rs index 19c0e72d05..76f101c3fe 100644 --- a/crates/perry-runtime/src/gc/copying.rs +++ b/crates/perry-runtime/src/gc/copying.rs @@ -498,6 +498,29 @@ impl CopyingNurseryCollector { } let total = (*header).size as usize; + // Safety net (partial mitigation, NOT a full fix): a genuine + // young/survivor object is always small — large objects are allocated + // old-gen/malloc, never in the copying nursery — so a "young" object + // whose size is out of range is a corrupt/mis-classified header (e.g. an + // off-heap pointer whose preceding bytes coincidentally pass + // `plausible_gc_header`). Refuse to memmove through such a garbage size: + // that turns the worst outcome (a wild out-of-bounds copy → SIGSEGV) + // into a no-op, and surfaces it under PERRY_GC_DIAG. It does NOT catch a + // plausible-but-wrong *small* size; the root fix is stronger arena + // classification / page unregistration so off-heap addresses never + // reach here. See the copying-minor relocation issue. + const MAX_YOUNG_MOVE_BYTES: usize = 1 << 20; // 1 MiB, >> any real young object + if total < GC_HEADER_SIZE || total > MAX_YOUNG_MOVE_BYTES { + if std::env::var_os("PERRY_GC_DIAG").is_some() { + eprintln!( + "[gc-move-guard] refusing wild young move user={:#x} obj_type={} size={}", + old_user as usize, + (*header).obj_type, + total + ); + } + return old_user as usize; + } let payload = total - GC_HEADER_SIZE; let prior_age = copied_survival_age((*header)._reserved, flags); let next_age = prior_age.saturating_add(1); From 51f71f3f4f5de2b0a6f67f6605c06514af28fc52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 8 Jul 2026 17:23:56 +0200 Subject: [PATCH 4/9] docs(gc): mark PERRY_GC_MOVING_SAFEPOINT experimental (exposes pre-existing relocation bugs) --- crates/perry-runtime/src/gc/policy.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index 88fd298474..7b30d6b52e 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -375,11 +375,15 @@ pub(crate) fn gc_note_external_side_free(bytes: usize) { } #[inline] -/// Phase 1+ of the moving-GC project (see the `project_gc_one_great_moving_gc` -/// design): gate the copying (moving) minor that runs at precise-root -/// safepoints. Default OFF while the moving path is validated; flipped on -/// (with `PERRY_GC_MOVING_SAFEPOINT=0` as the escape hatch) once moving is the -/// permanent default. +/// Phase 1 of the moving-GC project: gate the copying (moving) minor that runs +/// at precise-root event-loop safepoints. **EXPERIMENTAL — default OFF.** When +/// off, behaviour is exactly today's non-moving GC (this whole path is +/// skipped). Enabling it runs the copying minor, which is CORRECT on the +/// validated clean path (byte-identical output, survivors evacuated) but still +/// exposes PRE-EXISTING relocation-correctness bugs in the copying/evacuation +/// machinery (objects with an Array + a TypedArray field can corrupt on move; +/// see the copying-minor relocation issue) — so it can crash on programs that +/// hit those. Do NOT flip the default on until relocation is hardened. pub(crate) fn gc_moving_safepoint_enabled() -> bool { static CACHED: OnceLock = OnceLock::new(); *CACHED.get_or_init(|| { @@ -1328,7 +1332,7 @@ fn gc_budgeted_due_trigger() -> Option { /// non-moving minor. Trigger detection + re-baseline mirror the nursery-churn /// arm; this is purely additive (the alloc-point fallback is untouched) and /// gated by `gc_moving_safepoint_enabled` (default off). -pub(super) fn gc_safepoint_moving_minor() { +pub(crate) fn gc_safepoint_moving_minor() { // Same start guards the budgeted collector uses, minus the (here // irrelevant) scanner block: never collect mid-allocation, inside a // runtime handle scope, in an unsafe FFI zone, or during a budgeted cycle. From 4d6ee72c35398a3e324c6cf95b4074c1b1618b02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 8 Jul 2026 17:54:00 +0200 Subject: [PATCH 5/9] =?UTF-8?q?feat(gc):=20Phase=202/3=20=E2=80=94=20defer?= =?UTF-8?q?=20alloc-point=20minor=20to=20loop=20back-edge=20safepoints=20(?= =?UTF-8?q?moving=20primary)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the moving-GC project so the copying minor can be the PRIMARY collector for tight synchronous loops, not just at the event-loop boundary. All gated behind the experimental PERRY_GC_MOVING_SAFEPOINT opt-in (compile-time for the codegen polls, runtime for the collection); default binaries are unchanged and carry zero loop overhead. Phase 3 (deferral, runtime): when moving mode is on, the alloc-point nursery- churn arm no longer runs the conservative non-moving minor mid-expression — it sets GC_SAFEPOINT_PENDING and returns, deferring the collection to the next precise-root safepoint. A hard cap (256 MB committed) is the safety valve: a pathological single mega-expression that reaches no safepoint falls back to the non-moving minor so growth stays bounded. Phase 2 (codegen polls): emit js_gc_loop_safepoint() at loop back-edges (after clear_loop_body_shadow_slots, where the body expression has completed so roots are precise). The runtime poll drains a pending deferral by running the moving minor. Gated at compile time (moving_safepoint_polls_enabled) so default builds emit nothing. Status: validated end-to-end for the generic while/do-while/for back-edges (poll fires; output byte-identical to the non-moving GC). KNOWN GAP: the specialized/versioned for-loop lowering paths (i32-bound, packed-f64/i32/u32, bulk-fill) and for-of/for-in don't emit the poll yet, so a hot loop on one of those paths defers to the event-loop safepoint instead — the remaining Phase 2 codegen-coverage work, documented at emit_gc_loop_safepoint. --- .../perry-codegen/src/runtime_decls/arrays.rs | 5 ++ crates/perry-codegen/src/stmt/loops.rs | 37 ++++++++++++++ crates/perry-runtime/src/gc/policy.rs | 48 +++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/crates/perry-codegen/src/runtime_decls/arrays.rs b/crates/perry-codegen/src/runtime_decls/arrays.rs index 8c0efcfcd5..a3d2801945 100644 --- a/crates/perry-codegen/src/runtime_decls/arrays.rs +++ b/crates/perry-codegen/src/runtime_decls/arrays.rs @@ -87,6 +87,11 @@ pub fn declare_phase_b_arrays(module: &mut LlModule) { module.declare_function("js_shadow_slot_set", VOID, &[I32, I64]); module.declare_function("js_shadow_slot_bind", VOID, &[I32, PTR]); module.declare_function("js_gc_write_barriers_emitted", VOID, &[I32]); + // Phase 2 of the moving-GC project: emitted at loop back-edges (only when + // compiled with the moving-safepoint opt-in) so a deferred nursery + // collection can run at a precise-root safepoint. No-op at runtime unless + // moving mode is on and a collection is pending. + module.declare_function("js_gc_loop_safepoint", VOID, &[]); // Write barrier for the generational GC (Phase C per the // gen-GC plan). Called by codegen-emitted heap-store sites diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index 1504492518..6846ab8736 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -2654,6 +2654,7 @@ fn lower_for_after_init_with_i32_bound( ctx.block().asm_sideeffect_barrier(); } if !ctx.block().is_terminated() { + emit_gc_loop_safepoint(ctx); ctx.block().br(&update_label); } @@ -2707,6 +2708,40 @@ fn lower_for_after_init_with_i32_bound( Ok(()) } +/// Phase 2 of the moving-GC project: whether to emit loop back-edge safepoint +/// polls. Gated on the compiler being invoked with `PERRY_GC_MOVING_SAFEPOINT` +/// set, so default binaries carry ZERO loop overhead (no poll emitted at all). +fn moving_safepoint_polls_enabled() -> bool { + use std::sync::OnceLock; + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + matches!( + std::env::var("PERRY_GC_MOVING_SAFEPOINT").as_deref(), + Ok("1") | Ok("on") | Ok("true") + ) + }) +} + +/// Emit a `js_gc_loop_safepoint()` poll at a loop back-edge. Call this AFTER +/// `clear_loop_body_shadow_slots` and only where the block is not terminated: +/// at that point the loop-body expression has completed, so every live heap +/// value is a named local on the shadow stack (no unspilled register temps) — +/// a precise-root safepoint where a deferred copying minor can MOVE survivors. +/// +/// COVERAGE (Phase 2, follow-up): currently wired into the generic `while`, +/// `do..while`, and `for` back-edges. The specialized/versioned `for`-loop +/// lowering paths in this file (i32-bound-optimized, packed-f64/i32/u32, +/// bulk-fill) and `for-of`/`for-in` do NOT yet emit it, so a hot allocating +/// loop that takes one of those paths won't drain a deferred moving minor until +/// the next event-loop safepoint. Adding the poll to every back-edge across +/// those paths is the remaining Phase 2 codegen work. +pub(crate) fn emit_gc_loop_safepoint(ctx: &mut FnCtx<'_>) { + if !moving_safepoint_polls_enabled() || ctx.block().is_terminated() { + return; + } + ctx.block().call_void("js_gc_loop_safepoint", &[]); +} + pub(crate) fn clear_loop_body_shadow_slots(ctx: &mut FnCtx<'_>, body: &[Stmt]) { if ctx.block().is_terminated() || ctx.shadow_slot_map.is_empty() { return; @@ -4454,6 +4489,7 @@ pub(crate) fn lower_while( ctx.block().asm_sideeffect_barrier(); } if !ctx.block().is_terminated() { + emit_gc_loop_safepoint(ctx); ctx.block().br(&cond_label); } ctx.active_region_id = previous_region_id; @@ -4511,6 +4547,7 @@ pub(crate) fn lower_do_while( ctx.block().asm_sideeffect_barrier(); } if !ctx.block().is_terminated() { + emit_gc_loop_safepoint(ctx); ctx.block().br(&cond_label); } diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index 7b30d6b52e..c9d71dfd43 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -597,8 +597,21 @@ thread_local! { /// `gc_check_trigger`: the full collection must not recursively trigger /// another reclaim if a hook it runs allocates. pub(super) static GC_OLD_RECLAIM_IN_PROGRESS: Cell = const { Cell::new(false) }; + /// Phase 2/3 of the moving-GC project: set when an alloc-point nursery + /// trigger fires while moving mode is on, deferring the collection to the + /// next precise-root safepoint (event-loop boundary or a codegen loop + /// back-edge poll) so the copying minor can MOVE survivors instead of the + /// conservative non-moving minor running mid-expression. + pub(super) static GC_SAFEPOINT_PENDING: Cell = const { Cell::new(false) }; } +/// Hard cap on committed arena bytes before which a nursery trigger may be +/// deferred to a safepoint (Phase 2/3). Loop back-edge polls drain the pending +/// flag every iteration, so the arena never grows near this in normal code; +/// the cap only bounds a pathological single mega-expression that reaches no +/// poll — there the alloc-point non-moving minor runs as a safety valve. +pub(super) const GC_MOVING_DEFER_HARD_CAP_BYTES: usize = 256 * 1024 * 1024; + /// RAII guard that marks a #5476 direct old-gen reclaim in progress so a nested /// `gc_check_trigger` can't re-enter it. See `GC_OLD_RECLAIM_IN_PROGRESS`. struct OldReclaimReentryGuard; @@ -1180,6 +1193,19 @@ pub fn gc_check_trigger() { _ => None, }; if let Some(kind) = direct_kind { + // Phase 2/3: with moving mode on, DEFER this alloc-point collection + // to the next precise-root safepoint (event-loop boundary or a + // codegen loop back-edge poll) so the copying minor MOVES survivors + // instead of the conservative non-moving minor running here at a + // register-imprecise point. Safety valve: once committed arena bytes + // pass the hard cap (a mega-expression that reached no poll), fall + // through and collect non-moving here so growth stays bounded. + if gc_moving_safepoint_enabled() + && crate::arena::arena_total_bytes() < GC_MOVING_DEFER_HARD_CAP_BYTES + { + GC_SAFEPOINT_PENDING.with(|p| p.set(true)); + return; + } let pre_in_use = crate::arena::arena_in_use_bytes(); let pre_malloc_count = malloc_object_count(); let _scan = super::roots::ManualGcScanGuard::force_full_scan(); @@ -1341,8 +1367,13 @@ pub(crate) fn gc_safepoint_moving_minor() { || GC_ROOT_LOCK_DEPTH.with(|depth| depth.get() != 0) || gc_budgeted_cycle_active() { + // Blocked right now — leave GC_SAFEPOINT_PENDING set so the next poll + // retries; do not clear it here. return; } + // We are handling this safepoint (collect or find nothing due): clear the + // deferral flag set by the alloc-point arm (Phase 2/3). + GC_SAFEPOINT_PENDING.with(|p| p.set(false)); // Only nursery-pressure triggers take the moving minor here; OldReclaim // stays on its existing full mark-sweep path. let kind = match gc_budgeted_due_trigger() { @@ -1364,6 +1395,23 @@ pub(crate) fn gc_safepoint_moving_minor() { } } +/// Phase 2 of the moving-GC project: codegen emits a call to this at loop +/// back-edges — but ONLY when the compiler was invoked with the moving-safepoint +/// opt-in, so default binaries carry zero loop overhead. At a back-edge the +/// loop-body expression has completed, so no heap value lives in an unspilled +/// register (every live value is a named local on the shadow stack): a +/// precise-root safepoint. If moving mode is on and an alloc-point nursery +/// trigger deferred a collection (`GC_SAFEPOINT_PENDING`), drain it here so the +/// copying minor MOVES survivors. Cheap no-op otherwise (one cached-bool load + +/// one thread-local read). +#[no_mangle] +pub extern "C" fn js_gc_loop_safepoint() { + if !gc_moving_safepoint_enabled() || !GC_SAFEPOINT_PENDING.with(Cell::get) { + return; + } + gc_safepoint_moving_minor(); +} + struct BudgetedGcStepGuard; impl BudgetedGcStepGuard { From d654febef8c00bffffb6a1b80abd775442e619d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 8 Jul 2026 18:14:37 +0200 Subject: [PATCH 6/9] feat(gc): make the moving (copying) GC the DEFAULT + opt-in incremental old-gen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flips the moving GC on by default — it is now "the" GC, not an experiment. The copying minor runs at the event-loop safepoint and at loop back-edge polls, moving survivors (compacting, O(survivors), no sweep); the alloc-point path defers to those safepoints. `PERRY_GC_MOVING_SAFEPOINT=0` is a single kill switch that reverts to the non-moving path for regression bisection — not a parallel fallback maze. Changes: - gc_moving_safepoint_enabled + moving_safepoint_polls_enabled default to ON (kill switch is an explicit =0/off/false), so both the runtime collection and the codegen loop polls are on by default and stay coherent. - Lower the deferral hard cap 256->128 MB so a synchronous loop on a specialized lowering path that doesn't yet emit the poll can't balloon RSS before the alloc-point valve fires. - Phase 4 (opt-in, PERRY_GC_INCREMENTAL, default off): unblock the incremental old-gen budgeted stepper without converting all 88 mutable root scanners — when on, registered_root_scanners_block_budgeted_gc() stops blocking on unbudgeted mutable scanners and the stepper runs them synchronously in its initial root-scan step (bounded initial-mark pause), then marks/sweeps the old gen incrementally. Validated: default output byte-identical to the kill switch across a spread of programs (classes/closures/Map/Set/WeakMap/async/recursion/JSON, retained graphs, object-keyed maps), moving fires by default (copied 171 / promoted 6722 on the stress test), zero crashes. Known hardening items (test + harden in place): #6132 (codegen Array+TypedArray bug — corrupts the heap, so moving can crash on those programs; highest priority), #6133 (old-page force-evac), and loop poll coverage on the specialized/for-of lowering paths. --- crates/perry-codegen/src/stmt/loops.rs | 10 ++--- crates/perry-runtime/src/gc/cycle.rs | 8 +++- crates/perry-runtime/src/gc/policy.rs | 54 +++++++++++++++++++------- crates/perry-runtime/src/gc/roots.rs | 9 +++++ 4 files changed, 61 insertions(+), 20 deletions(-) diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index 6846ab8736..dbb31553f3 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -2708,16 +2708,16 @@ fn lower_for_after_init_with_i32_bound( Ok(()) } -/// Phase 2 of the moving-GC project: whether to emit loop back-edge safepoint -/// polls. Gated on the compiler being invoked with `PERRY_GC_MOVING_SAFEPOINT` -/// set, so default binaries carry ZERO loop overhead (no poll emitted at all). +/// Whether to emit loop back-edge safepoint polls — ON by default (the moving +/// GC is the default). Compiling with `PERRY_GC_MOVING_SAFEPOINT=0` omits them +/// (the kill switch), matching the runtime default so the two stay coherent. fn moving_safepoint_polls_enabled() -> bool { use std::sync::OnceLock; static CACHED: OnceLock = OnceLock::new(); *CACHED.get_or_init(|| { - matches!( + !matches!( std::env::var("PERRY_GC_MOVING_SAFEPOINT").as_deref(), - Ok("1") | Ok("on") | Ok("true") + Ok("0") | Ok("off") | Ok("false") ) }) } diff --git a/crates/perry-runtime/src/gc/cycle.rs b/crates/perry-runtime/src/gc/cycle.rs index 65dc28fa49..910bc90071 100644 --- a/crates/perry-runtime/src/gc/cycle.rs +++ b/crates/perry-runtime/src/gc/cycle.rs @@ -1073,7 +1073,13 @@ impl GcCycleState { .is_some_and(|minor| minor.evacuation_policy.considered); self.root_scan.get_or_insert_with(RootScanCycleState::new); - let allow_synchronous_scanners = !self.progress_kind.is_budgeted(); + // Phase 4 (incremental old-gen, gated): allow the initial root-scan step + // of a budgeted cycle to run unbudgeted scanners synchronously (a + // bounded initial-mark pause), so the stepper can start on programs that + // register unbudgeted mutable scanners. Marking still proceeds + // incrementally in later steps (they don't consult this flag). + let allow_synchronous_scanners = + !self.progress_kind.is_budgeted() || super::gc_incremental_enabled(); loop { let phase_name = self .root_scan diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index c9d71dfd43..661ef62303 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -375,20 +375,43 @@ pub(crate) fn gc_note_external_side_free(bytes: usize) { } #[inline] -/// Phase 1 of the moving-GC project: gate the copying (moving) minor that runs -/// at precise-root event-loop safepoints. **EXPERIMENTAL — default OFF.** When -/// off, behaviour is exactly today's non-moving GC (this whole path is -/// skipped). Enabling it runs the copying minor, which is CORRECT on the -/// validated clean path (byte-identical output, survivors evacuated) but still -/// exposes PRE-EXISTING relocation-correctness bugs in the copying/evacuation -/// machinery (objects with an Array + a TypedArray field can corrupt on move; -/// see the copying-minor relocation issue) — so it can crash on programs that -/// hit those. Do NOT flip the default on until relocation is hardened. +/// The moving (copying) minor at precise-root safepoints — **Perry's default +/// GC.** The copying minor runs at the event-loop safepoint and, via the +/// codegen loop back-edge polls, at loop safepoints; it moves survivors +/// (compacting, O(survivors), no sweep). `PERRY_GC_MOVING_SAFEPOINT=0` is a +/// kill switch that reverts to the non-moving path (for bisecting a regression +/// while we harden). Known hardening items: programs that hit the codegen +/// Array+TypedArray bug (#6132) can corrupt the heap and crash the collector; +/// the old-page force-evacuation path (#6133); and loop back-edge poll coverage +/// for the specialized loop-lowering paths. pub(crate) fn gc_moving_safepoint_enabled() -> bool { static CACHED: OnceLock = OnceLock::new(); + // Default ON; the kill switch is an explicit `=0`/`off`/`false`. *CACHED.get_or_init(|| { - matches!( + !matches!( std::env::var("PERRY_GC_MOVING_SAFEPOINT").as_deref(), + Ok("0") | Ok("off") | Ok("false") + ) + }) +} + +/// Phase 4 of the moving-GC project: gate the INCREMENTAL old-gen collector (the +/// budgeted stepper). **EXPERIMENTAL — default OFF.** Perry has a full budgeted +/// mark/sweep stepper but it never runs, because every compiled program +/// registers unbudgeted mutable root scanners and +/// `registered_root_scanners_block_budgeted_gc()` blocks the cycle from ever +/// starting. When this is on, the stepper is allowed to start and runs those +/// unbudgeted scanners SYNCHRONOUSLY in its initial root-scan step (a bounded +/// initial-mark pause), then marks/sweeps the old gen incrementally across +/// safepoints — the standard "initial-mark + incremental-mark" design. Off ⇒ +/// exactly today's non-incremental GC (the whole path is skipped). Independent +/// of `PERRY_GC_MOVING_SAFEPOINT`; this is the concurrency layer that reduces +/// old-gen pause time. +pub(crate) fn gc_incremental_enabled() -> bool { + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + matches!( + std::env::var("PERRY_GC_INCREMENTAL").as_deref(), Ok("1") | Ok("on") | Ok("true") ) }) @@ -607,10 +630,13 @@ thread_local! { /// Hard cap on committed arena bytes before which a nursery trigger may be /// deferred to a safepoint (Phase 2/3). Loop back-edge polls drain the pending -/// flag every iteration, so the arena never grows near this in normal code; -/// the cap only bounds a pathological single mega-expression that reaches no -/// poll — there the alloc-point non-moving minor runs as a safety valve. -pub(super) const GC_MOVING_DEFER_HARD_CAP_BYTES: usize = 256 * 1024 * 1024; +/// flag every iteration, so the arena never grows near this in normal code; the +/// cap bounds RSS for code that reaches no safepoint before the next trigger — +/// a synchronous loop on a specialized lowering path that doesn't yet emit the +/// poll, or a single mega-expression — where the alloc-point non-moving minor +/// runs as the safety valve. Kept modest so those cases don't balloon under the +/// default-on moving GC (raise once poll coverage is complete). +pub(super) const GC_MOVING_DEFER_HARD_CAP_BYTES: usize = 128 * 1024 * 1024; /// RAII guard that marks a #5476 direct old-gen reclaim in progress so a nested /// `gc_check_trigger` can't re-enter it. See `GC_OLD_RECLAIM_IN_PROGRESS`. diff --git a/crates/perry-runtime/src/gc/roots.rs b/crates/perry-runtime/src/gc/roots.rs index 35e64bbd11..fae364db35 100644 --- a/crates/perry-runtime/src/gc/roots.rs +++ b/crates/perry-runtime/src/gc/roots.rs @@ -286,6 +286,15 @@ pub(super) fn copy_only_root_scanner_counts() -> (usize, usize) { pub(super) fn registered_root_scanners_block_budgeted_gc() -> bool { let has_copy_only = ROOT_SCANNERS.with(|scanners| !scanners.borrow().is_empty()) || FFI_ROOT_SCANNERS.with(|scanners| !scanners.borrow().is_empty()); + if super::gc_incremental_enabled() { + // Phase 4 (incremental old-gen, gated): unbudgeted MUTABLE / ffi-mutable + // scanners no longer block the cycle — they run synchronously in the + // initial root-scan step (see `allow_synchronous_scanners`), a bounded + // initial-mark pause. Copy-only scanners still block: they can only + // mark, not rewrite, so a moving/incremental cycle can't tolerate them. + // (There are ~none in production — only the FFI copy-only API + tests.) + return has_copy_only; + } let has_unbudgeted_mutable = MUTABLE_ROOT_SCANNERS.with(|scanners| { scanners .borrow() From 657b0159f412397bd9db410e9d0cbab7e77a6ca8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 8 Jul 2026 18:50:03 +0200 Subject: [PATCH 7/9] =?UTF-8?q?fix(codegen):=20#6132=20=E2=80=94=20typed-a?= =?UTF-8?q?rray=20element=20loop=20reads=20garbage=20when=20receiver=20is?= =?UTF-8?q?=20a=20member?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `for (let i = 0; i < n.buf.length; i++) ... n.buf[i]` where `n.buf` is a Uint32Array (or any typed array) accessed as a MEMBER expression returned garbage / nondeterministic junk, corrupting the heap (which then crashed the moving GC). Root cause: a member receiver with a loop-variable integer index and no proven numeric layout was lowered via lower_legacy_array_index_get, which inline-reads the value as a plain ArrayHeader — gc_type at `handle-8`, raw f64 slot at `handle+8+i*8`. A small typed array is allocated OFF the GC heap with no GcHeader, so both reads land on unrelated bytes: the dispatch routes nondeterministically and the "fast" path returns raw garbage. Fix: route that case through lower_guarded_array_index_get instead. Its runtime typed-feedback guard rejects non-plain arrays and takes the boxed fallback (which dispatches typed arrays correctly), while plain arrays keep the inline fast path — so regular-array member loops are unaffected and typed-array member loops are correct. lower_legacy_array_index_get is now unused (retired). Validated: the case matrix (member typed-array loop, first/only, after another loop, in a helper, local-alias, direct) matches Node exactly; the moving-GC stress test that used to crash ~4/5 runs now crashes 0/6; gc_valid / object-keyed Map/WeakMap / high-volume loop / class+closure smoke unchanged. (A separate, smaller non-GC nondeterminism remains in the heaviest mixed repro; filed apart.) --- crates/perry-codegen/src/expr/index_get.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/perry-codegen/src/expr/index_get.rs b/crates/perry-codegen/src/expr/index_get.rs index b2fe5e3c0f..9a52b86ef8 100644 --- a/crates/perry-codegen/src/expr/index_get.rs +++ b/crates/perry-codegen/src/expr/index_get.rs @@ -1173,6 +1173,10 @@ fn lower_bounded_array_index_get( )) } +// #6132: retired — inline-read a value as a plain ArrayHeader, which is unsafe +// for off-heap typed arrays (garbage reads). Callers now use the typed-feedback +// guarded path. Kept temporarily behind allow(dead_code); slated for deletion. +#[allow(dead_code)] fn lower_legacy_array_index_get( ctx: &mut FnCtx<'_>, arr_box: &str, @@ -1658,11 +1662,15 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { )); } let idx_i32 = lower_expr_as_i32(ctx, index)?; - if !require_numeric_layout - && !matches!(index.as_ref(), Expr::Integer(_) | Expr::Number(_)) - { - return lower_legacy_array_index_get(ctx, &arr_box, &idx_i32); - } + // #6132: a member receiver of unknown type (e.g. `n.buf[i]` where + // `n.buf` is a Uint32Array) must NOT go through the legacy inline + // reader — that path reads the value as a plain `ArrayHeader` + // (gc_type at `handle-8`, raw f64 slot at `handle+8+i*8`), but a + // small typed array is off-heap with no GcHeader, so both reads + // are garbage and it returned nondeterministic junk. Route through + // the typed-feedback-guarded path: its runtime guard rejects + // non-plain arrays and takes the boxed fallback (which dispatches + // typed arrays correctly), while plain arrays keep the fast path. return lower_guarded_array_index_get( ctx, &arr_box, From 485a8bcebc90d2a1bf331feb8942cc6dc73c4274 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 8 Jul 2026 18:53:26 +0200 Subject: [PATCH 8/9] chore(gc): include promoted_bytes in the [gc-copy-minor] diagnostic (CodeRabbit) --- crates/perry-runtime/src/gc/copying.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/gc/copying.rs b/crates/perry-runtime/src/gc/copying.rs index 76f101c3fe..f6b7096005 100644 --- a/crates/perry-runtime/src/gc/copying.rs +++ b/crates/perry-runtime/src/gc/copying.rs @@ -1153,10 +1153,11 @@ pub(super) fn gc_collect_minor_copying_fast_path_with_eligibility( maybe_schedule_old_reclaim_after_copied_minor(); if std::env::var_os("PERRY_GC_DIAG").is_some() { eprintln!( - "[gc-copy-minor] ran copied_objects={} copied_bytes={} promoted_objects={} freed_bytes={}", + "[gc-copy-minor] ran copied_objects={} copied_bytes={} promoted_objects={} promoted_bytes={} freed_bytes={}", collector.stats.copied_objects, collector.stats.copied_bytes, collector.stats.promoted_objects, + collector.stats.promoted_bytes, freed_bytes ); } From aaf149894d2e4803b8f1fff58383c4e93b6ae650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 8 Jul 2026 19:16:08 +0200 Subject: [PATCH 9/9] =?UTF-8?q?fix(gc):=20loop=20back-edge=20polls=20are?= =?UTF-8?q?=20opt-in=20(they=20defeat=20vectorization)=20=E2=80=94=20keep?= =?UTF-8?q?=20event-loop=20moving=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compiler-output-regression caught it: emitting js_gc_loop_safepoint() at every loop back-edge (default-on in the prior commit) inserts a CALL into hot numeric loops, which defeats LLVM auto-vectorization and violates the native-region "no runtime calls in hot loop" proofs (image_convolution, packed_f64 versioning, h1_* buffer regions). That's a real, broad perf regression. Split the concern: - Phase 1 (moving minor at the EVENT-LOOP safepoint) stays the DEFAULT — it's pure runtime, no codegen change, no per-loop cost. Moving still fires by default (validated: gc_valid eligible=true, byte-identical). - Phase 2/3 (loop back-edge polls + alloc-point deferral, making moving PRIMARY inside loops) is now opt-in behind PERRY_GC_MOVING_LOOP_POLLS (compile-time poll emission + runtime deferral/poll gate, kept coherent). Off by default until the poll is emitted only for loops that actually ALLOCATE, so numeric/vectorizable loops stay call-free. Verified: default numeric-loop IR has 0 js_gc_loop_safepoint (was the regression); PERRY_GC_MOVING_LOOP_POLLS=1 emits it; #6132 matrix still matches Node; gc_valid unchanged. --- crates/perry-codegen/src/stmt/loops.rs | 17 +++++++---- crates/perry-runtime/src/gc/policy.rs | 39 ++++++++++++++++++-------- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index dbb31553f3..c97ec39a56 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -2708,16 +2708,21 @@ fn lower_for_after_init_with_i32_bound( Ok(()) } -/// Whether to emit loop back-edge safepoint polls — ON by default (the moving -/// GC is the default). Compiling with `PERRY_GC_MOVING_SAFEPOINT=0` omits them -/// (the kill switch), matching the runtime default so the two stay coherent. +/// Whether to emit loop back-edge safepoint polls — OPT-IN, default OFF +/// (`PERRY_GC_MOVING_LOOP_POLLS=1`). The moving GC is the default at the +/// event-loop safepoint, but the loop poll emits a `js_gc_loop_safepoint()` +/// CALL at every loop back-edge, which defeats LLVM auto-vectorization and +/// violates the native-region "no runtime calls in hot loop" proofs. Until the +/// poll is emitted only in loops that actually ALLOCATE (so numeric/vectorizable +/// loops stay call-free), it is opt-in and a tight allocating loop defers to the +/// event-loop safepoint instead. fn moving_safepoint_polls_enabled() -> bool { use std::sync::OnceLock; static CACHED: OnceLock = OnceLock::new(); *CACHED.get_or_init(|| { - !matches!( - std::env::var("PERRY_GC_MOVING_SAFEPOINT").as_deref(), - Ok("0") | Ok("off") | Ok("false") + matches!( + std::env::var("PERRY_GC_MOVING_LOOP_POLLS").as_deref(), + Ok("1") | Ok("on") | Ok("true") ) }) } diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index 661ef62303..ae0ef7a22e 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -375,15 +375,15 @@ pub(crate) fn gc_note_external_side_free(bytes: usize) { } #[inline] -/// The moving (copying) minor at precise-root safepoints — **Perry's default -/// GC.** The copying minor runs at the event-loop safepoint and, via the -/// codegen loop back-edge polls, at loop safepoints; it moves survivors -/// (compacting, O(survivors), no sweep). `PERRY_GC_MOVING_SAFEPOINT=0` is a -/// kill switch that reverts to the non-moving path (for bisecting a regression -/// while we harden). Known hardening items: programs that hit the codegen -/// Array+TypedArray bug (#6132) can corrupt the heap and crash the collector; -/// the old-page force-evacuation path (#6133); and loop back-edge poll coverage -/// for the specialized loop-lowering paths. +/// The moving (copying) minor at the precise-root event-loop safepoint — +/// **Perry's default GC.** At the outermost microtask-pump boundary the JS stack +/// has unwound, so the copying minor runs with precise, rewritable roots and +/// moves survivors (compacting, O(survivors), no sweep). `PERRY_GC_MOVING_SAFEPOINT=0` +/// is a kill switch that reverts to the non-moving path (for bisecting a +/// regression while we harden). Making the moving minor primary INSIDE loops +/// (back-edge polls + alloc-point deferral) is the separate opt-in +/// `PERRY_GC_MOVING_LOOP_POLLS` — off by default because the poll defeats loop +/// vectorization until it is emitted only for allocating loops. pub(crate) fn gc_moving_safepoint_enabled() -> bool { static CACHED: OnceLock = OnceLock::new(); // Default ON; the kill switch is an explicit `=0`/`off`/`false`. @@ -417,6 +417,23 @@ pub(crate) fn gc_incremental_enabled() -> bool { }) } +/// Phase 2/3 (opt-in, default OFF): also make the moving minor PRIMARY inside +/// loops — defer the alloc-point nursery collection to a codegen loop back-edge +/// poll (`js_gc_loop_safepoint`) instead of collecting non-moving mid-expression. +/// Off by default because the poll emits a call in every loop, defeating +/// vectorization; when it's emitted only for allocating loops this can flip on. +/// Must match the codegen `moving_safepoint_polls_enabled` (same env) so the +/// deferral and the polls that drain it stay coherent. +pub(crate) fn gc_moving_loop_polls_enabled() -> bool { + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + matches!( + std::env::var("PERRY_GC_MOVING_LOOP_POLLS").as_deref(), + Ok("1") | Ok("on") | Ok("true") + ) + }) +} + pub(super) fn gc_trace_enabled() -> bool { #[cfg(test)] if GC_TRACE_TEST_FORCE.with(Cell::get) { @@ -1226,7 +1243,7 @@ pub fn gc_check_trigger() { // register-imprecise point. Safety valve: once committed arena bytes // pass the hard cap (a mega-expression that reached no poll), fall // through and collect non-moving here so growth stays bounded. - if gc_moving_safepoint_enabled() + if gc_moving_loop_polls_enabled() && crate::arena::arena_total_bytes() < GC_MOVING_DEFER_HARD_CAP_BYTES { GC_SAFEPOINT_PENDING.with(|p| p.set(true)); @@ -1432,7 +1449,7 @@ pub(crate) fn gc_safepoint_moving_minor() { /// one thread-local read). #[no_mangle] pub extern "C" fn js_gc_loop_safepoint() { - if !gc_moving_safepoint_enabled() || !GC_SAFEPOINT_PENDING.with(Cell::get) { + if !gc_moving_loop_polls_enabled() || !GC_SAFEPOINT_PENDING.with(Cell::get) { return; } gc_safepoint_moving_minor();