diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index 08bee9840e..62b29b80fd 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -367,6 +367,15 @@ pub(crate) fn gc_incremental_enabled() -> bool { /// deferral and the polls that drain it stay coherent — a runtime default-on with /// a codegen default-off (or vice versa) would defer collections that never drain. pub(crate) fn gc_moving_loop_polls_enabled() -> bool { + // Test-only mode override (see `force_legacy_gc_pacing`). Consulted BEFORE + // the process-wide OnceLock so a single test can pin legacy (non-moving, + // budgeted/direct, 128 MiB-ceiling) pacing for its duration even though the + // process default is moving-on. Compiled out entirely in release builds. + #[cfg(test)] + if let Some(forced) = GC_MOVING_LOOP_POLLS_TEST_OVERRIDE.with(Cell::get) { + return forced; + } + static CACHED: OnceLock = OnceLock::new(); *CACHED.get_or_init(|| { !matches!( @@ -376,6 +385,50 @@ pub(crate) fn gc_moving_loop_polls_enabled() -> bool { }) } +#[cfg(test)] +thread_local! { + /// Test-only override for [`gc_moving_loop_polls_enabled`]. When `Some(v)`, + /// the getter returns `v` before consulting the process-wide OnceLock. This + /// is the ONLY way a unit test can select GC pacing mode per-test: the + /// OnceLock caches the env-derived default once for the whole process, so + /// the entire test binary otherwise runs in a single mode. Because the + /// nursery-cap in `effective_next_arena_trigger`, the alloc-point routing in + /// `gc_check_trigger`, and the eager malloc-registry build in + /// `CopyingPointerSet::new` all consult `gc_moving_loop_polls_enabled()` + /// (and `gc_scavenge_enabled()` is env-gated OFF by default in tests), this + /// single override flips all of the moving-mode behavior coherently. + static GC_MOVING_LOOP_POLLS_TEST_OVERRIDE: Cell> = const { Cell::new(None) }; +} + +/// RAII guard that pins LEGACY (non-moving, budgeted/direct, 128 MiB-ceiling) GC +/// pacing for the tests that assert the budgeted/direct pacer + trigger +/// arithmetic — the mechanism that, since the moving-nursery default-on flip, +/// lives behind the `PERRY_GC_MOVING_LOOP_POLLS=0` kill switch rather than the +/// default path. Restores the previous override state on drop. Test-only. +#[cfg(test)] +pub(super) struct LegacyGcPacingGuard { + previous: Option, +} + +#[cfg(test)] +impl Drop for LegacyGcPacingGuard { + fn drop(&mut self) { + GC_MOVING_LOOP_POLLS_TEST_OVERRIDE.with(|cell| cell.set(self.previous)); + } +} + +/// Pin legacy GC pacing (moving-loop polls OFF) for the duration of the returned +/// guard. See [`LegacyGcPacingGuard`] and [`gc_moving_loop_polls_enabled`]. +#[cfg(test)] +pub(super) fn force_legacy_gc_pacing() -> LegacyGcPacingGuard { + let previous = GC_MOVING_LOOP_POLLS_TEST_OVERRIDE.with(|cell| { + let previous = cell.get(); + cell.set(Some(false)); + previous + }); + LegacyGcPacingGuard { previous } +} + pub(super) fn gc_trace_enabled() -> bool { #[cfg(test)] if GC_TRACE_TEST_FORCE.with(Cell::get) { diff --git a/crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs b/crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs index a19e2af920..9e9a2e2488 100644 --- a/crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs +++ b/crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs @@ -269,6 +269,7 @@ fn test_copying_minor_sweeps_malloc_when_due_on_arena_trigger() { #[test] fn test_gc_check_trigger_copied_minor_malloc_sweep_rebaselines_trigger() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _guard = CopyingNurseryTestGuard::new(1); let trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); let live_malloc = gc_malloc( @@ -340,6 +341,7 @@ fn test_gc_check_trigger_copied_minor_malloc_sweep_rebaselines_trigger() { #[test] fn test_gc_check_trigger_copied_minor_without_malloc_sweep_preserves_malloc_trigger() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _guard = CopyingNurseryTestGuard::new(1); let trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); deactivate_malloc_registry_for_tests(); @@ -407,6 +409,7 @@ fn test_gc_check_trigger_copied_minor_without_malloc_sweep_preserves_malloc_trig #[test] fn test_copied_minor_malloc_scaling_no_roots_skips_registry_walk() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _guard = CopyingNurseryTestGuard::new(1); let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); deactivate_malloc_registry_for_tests(); @@ -488,6 +491,7 @@ fn test_copied_minor_malloc_scaling_live_root_with_active_registry() { #[test] fn test_copied_minor_malloc_scaling_falls_back_when_registry_unavailable() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _guard = CopyingNurseryTestGuard::new(0); let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); let live_malloc = gc_malloc( diff --git a/crates/perry-runtime/src/gc/tests/debt_pacer.rs b/crates/perry-runtime/src/gc/tests/debt_pacer.rs index 2f38dbcf64..74f2894ab2 100644 --- a/crates/perry-runtime/src/gc/tests/debt_pacer.rs +++ b/crates/perry-runtime/src/gc/tests/debt_pacer.rs @@ -32,6 +32,7 @@ fn budgeted_step_until_phase(target: GcCyclePhase) -> JsGcStepResult { #[test] fn arena_threshold_debt_starts_bounded_assist_without_monolithic_collection() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _guard = CopyingNurseryTestGuard::new(1); let trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); reset_old_reclaim_pressure(); @@ -74,6 +75,7 @@ fn arena_threshold_debt_starts_bounded_assist_without_monolithic_collection() { #[test] fn malloc_threshold_debt_reclaims_dead_churn_after_host_drain() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _guard = CopyingNurseryTestGuard::new(1); let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); reset_old_reclaim_pressure(); @@ -131,6 +133,7 @@ fn malloc_threshold_debt_reclaims_dead_churn_after_host_drain() { #[test] fn active_cycle_gc_check_trigger_calls_pay_bounded_assist_work() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _guard = CopyingNurseryTestGuard::new(1); let trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); reset_old_reclaim_pressure(); @@ -181,6 +184,7 @@ fn active_cycle_gc_check_trigger_calls_pay_bounded_assist_work() { /// covered separately in `incremental_sweep_reclaim.rs`. #[test] fn allocation_assists_complete_finalize_sweep_and_reclaim() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _guard = CopyingNurseryTestGuard::new(1); let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); reset_old_reclaim_pressure(); @@ -271,6 +275,7 @@ fn noop_copy_only_root_scanner(_visit: &mut dyn FnMut(f64)) {} /// bounded live set that never made progress. #[test] fn direct_arena_minor_rebaselines_trigger_above_live_set() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _nursery = CopyingNurseryTestGuard::new(1); // A registered copy-only scanner makes the budgeted stepper ineligible, so // gc_check_trigger takes the direct synchronous-minor arm. @@ -318,6 +323,7 @@ fn direct_arena_minor_rebaselines_trigger_above_live_set() { /// re-arms a full synchronous minor. #[test] fn direct_malloc_minor_rebaselines_trigger_above_survivors() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _nursery = CopyingNurseryTestGuard::new(1); let _scanners = ScopedRootScannerRegistryGuard::new(); gc_register_root_scanner(noop_copy_only_root_scanner); @@ -407,6 +413,7 @@ fn mutator_assist_work_units_scale_with_debt() { /// supply but 300 debt-scaled assists comfortably can. #[test] fn debt_scaled_assists_cannot_be_outrun_by_allocation() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _guard = CopyingNurseryTestGuard::new(1); let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); reset_old_reclaim_pressure(); @@ -457,6 +464,7 @@ fn debt_scaled_assists_cannot_be_outrun_by_allocation() { /// the barrier window), and birth flags reset once the cycle completes. #[test] fn budgeted_cycle_allocations_are_born_marked_for_the_whole_cycle() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _guard = CopyingNurseryTestGuard::new(1); let trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); reset_old_reclaim_pressure(); @@ -500,6 +508,7 @@ fn budgeted_cycle_allocations_are_born_marked_for_the_whole_cycle() { /// objects (measured as the #6224 stress SIGSEGV). #[test] fn manual_gc_drains_parked_budgeted_cycle_first() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _guard = CopyingNurseryTestGuard::new(1); let trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); reset_old_reclaim_pressure(); @@ -748,6 +757,13 @@ fn test_arena_debt_measured_against_effective_trigger_not_raw_cell() { effective_next_arena_trigger, GC_NEXT_TRIGGER_BYTES, GC_TRIGGER_ARMED, }; + // Asserts the legacy pre-first-collection trigger arithmetic (un-armed cell + // reads as the 128 MiB device ceiling). Under default-on moving the effective + // trigger is nursery-capped (16 MiB); pin legacy to keep testing the raw-vs- + // effective debt mechanism this test was written for. (The nursery-cap value + // is asserted under the default by triggers::test_effective_arena_trigger_respects_armed_values.) + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); + let prev_total = crate::arena::ARENA_TOTAL_BYTES.with(|c| c.get()); let prev_trigger = GC_NEXT_TRIGGER_BYTES.with(|c| c.get()); let prev_armed = GC_TRIGGER_ARMED.with(|c| c.get()); diff --git a/crates/perry-runtime/src/gc/tests/roots.rs b/crates/perry-runtime/src/gc/tests/roots.rs index 5bf82162d5..82809bea56 100644 --- a/crates/perry-runtime/src/gc/tests/roots.rs +++ b/crates/perry-runtime/src/gc/tests/roots.rs @@ -100,6 +100,7 @@ impl Drop for GcUnsafeZoneResetGuard { #[test] fn lock_safe_runtime_scanners_tui_state_defers_gc_check_trigger() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _test_lock = lock_safe_runtime_scanner_test_guard(); let _reset = ShadowAndGlobalRootResetGuard; ensure_lock_safe_runtime_scanners_registered(); diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots.rs b/crates/perry-runtime/src/gc/tests/runtime_roots.rs index 6e21a175e6..bbe852fa37 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots.rs @@ -987,6 +987,7 @@ fn test_set_gc_field_rewrite_reindexes_elements() { #[test] fn test_transient_runtime_handle_string_concat_gc() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _guard = CopyingNurseryTestGuard::new(0); let trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); register_runtime_handle_root_scanner_for_tests(); @@ -1016,6 +1017,7 @@ fn test_transient_runtime_handle_string_concat_gc() { #[test] fn test_dynamic_string_add_roots_left_string_across_rhs_coercion_gc() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _guard = CopyingNurseryTestGuard::new(0); let trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); register_runtime_handle_root_scanner_for_tests(); @@ -1053,6 +1055,7 @@ fn test_dynamic_string_add_roots_left_string_across_rhs_coercion_gc() { #[test] fn test_dynamic_bigint_add_roots_both_bigint_across_gc() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); // #2908: a BigInt operator now requires BOTH operands to be BigInt // (`1n + 1` throws TypeError instead of coercing). This test exercises // the same GC-rooting guarantee — the left BigInt must survive a minor @@ -1102,6 +1105,7 @@ fn test_dynamic_bigint_add_roots_both_bigint_across_gc() { #[test] fn test_bigint_method_add_roots_receiver_across_rhs_number_coercion_gc() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _guard = CopyingNurseryTestGuard::new(0); let trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); register_runtime_handle_root_scanner_for_tests(); @@ -1145,6 +1149,7 @@ fn test_bigint_method_add_roots_receiver_across_rhs_number_coercion_gc() { #[test] fn test_string_method_split_roots_receiver_across_separator_materialization_gc() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _guard = CopyingNurseryTestGuard::new(0); let trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); activate_malloc_registry_for_tests(); @@ -1189,6 +1194,7 @@ fn test_string_method_split_roots_receiver_across_separator_materialization_gc() #[test] fn test_string_method_replace_roots_receiver_across_pattern_materialization_gc() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _guard = CopyingNurseryTestGuard::new(0); let trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); activate_malloc_registry_for_tests(); @@ -1232,6 +1238,7 @@ fn test_string_method_replace_roots_receiver_across_pattern_materialization_gc() #[test] fn test_transient_runtime_handle_array_push_gc() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _guard = CopyingNurseryTestGuard::new(0); let trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); register_runtime_handle_root_scanner_for_tests(); @@ -1261,6 +1268,7 @@ fn test_transient_runtime_handle_array_push_gc() { #[test] fn test_transient_runtime_handle_object_set_gc() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _guard = CopyingNurseryTestGuard::new(1); let trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); register_runtime_handle_root_scanner_for_tests(); @@ -1297,6 +1305,7 @@ fn test_transient_runtime_handle_object_set_gc() { #[test] fn test_transient_runtime_handle_closure_captures_gc() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); extern "C" fn captured_func(_closure: *const crate::closure::ClosureHeader) -> f64 { 0.0 } @@ -1571,6 +1580,7 @@ fn drain_promise_microtasks_for_test() { #[test] fn test_async_hook_option_lookup_roots_callbacks_across_copied_minor_gc() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _async_hook_guard = AsyncHookRuntimeTestGuard::new(); let _guard = CopyingNurseryTestGuard::new(0); let trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); @@ -1592,6 +1602,7 @@ fn test_async_hook_option_lookup_roots_callbacks_across_copied_minor_gc() { #[test] fn test_closure_rest_dispatch_roots_args_during_rest_array_alloc_gc() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _async_hook_guard = AsyncHookRuntimeTestGuard::new(); let _guard = CopyingNurseryTestGuard::new(0); let trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/string_slice.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/string_slice.rs index a87e58e423..fd182ba49d 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/string_slice.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/string_slice.rs @@ -11,6 +11,7 @@ use super::*; /// allocation. This mirrors the proven concat / dynamic-add tests in the parent. #[test] fn test_transient_runtime_handle_string_slice_gc() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let _guard = CopyingNurseryTestGuard::new(0); let trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); register_runtime_handle_root_scanner_for_tests(); diff --git a/crates/perry-runtime/src/gc/tests/triggers.rs b/crates/perry-runtime/src/gc/tests/triggers.rs index 598dcf36eb..1a52c3b8ae 100644 --- a/crates/perry-runtime/src/gc/tests/triggers.rs +++ b/crates/perry-runtime/src/gc/tests/triggers.rs @@ -157,6 +157,15 @@ fn test_gc_bump_medium_parse_allows_one_arena_bump_per_gc_cycle() { #[test] fn test_gc_bump_never_lowers_existing_arena_trigger() { + // The "never lower" invariant is asserted against the RAW trigger cell, whose + // relationship to the bump target only holds under legacy pacing: with moving + // mode on, `effective_next_arena_trigger` is clamped to the small nursery cap, + // so a bump target above that cap legitimately re-arms the cell (without ever + // lowering the EFFECTIVE, capped trigger). Pin legacy to keep asserting the + // raw-cell arithmetic this test was written for. (The new nursery-cap value + // itself is asserted under the default by + // `test_effective_arena_trigger_respects_armed_values`.) + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); let existing_trigger = GC_TRIGGER_ABSOLUTE_CEILING + (32 * 1024 * 1024); let _guard = GcBumpTriggerTestGuard::new(existing_trigger, GC_THRESHOLD_INITIAL_BYTES); let bytes_now = GC_TRIGGER_ABSOLUTE_CEILING + (16 * 1024 * 1024); @@ -248,26 +257,49 @@ fn test_budget_scaled_clamps_only_under_budget() { fn test_effective_arena_trigger_respects_armed_values() { use super::super::heap_budget::gc_trigger_absolute_ceiling_bytes; use super::super::policy::{ - effective_next_arena_trigger, GC_NEXT_TRIGGER_BYTES, GC_TRIGGER_ARMED, + effective_next_arena_trigger, gc_moving_loop_polls_enabled, gc_scavenge_nursery_cap_bytes, + GC_NEXT_TRIGGER_BYTES, GC_TRIGGER_ARMED, }; + // `effective_next_arena_trigger` additionally clamps to the small nursery cap + // whenever moving mode is active (the default-on evacuating scavenge) or the + // PERRY_GC_SCAVENGE de-risking flag is set; otherwise it clamps only the + // UN-armed cell to the device ceiling and lets an armed trigger exceed it. + // Assert the value correct for the mode this process runs in, so the NEW + // nursery-cap behavior is exercised under the default and the legacy ceiling + // behavior under the PERRY_GC_MOVING_LOOP_POLLS=0 kill switch. This mirrors + // the gate in `effective_next_arena_trigger` exactly. + let nursery_capped = super::super::gc_scavenge_enabled() || gc_moving_loop_polls_enabled(); + let ceiling = gc_trigger_absolute_ceiling_bytes(); + let nursery_cap = gc_scavenge_nursery_cap_bytes(); + let prev_trigger = GC_NEXT_TRIGGER_BYTES.with(|c| c.get()); let prev_armed = GC_TRIGGER_ARMED.with(|c| c.get()); GC_TRIGGER_ARMED.with(|c| c.set(false)); GC_NEXT_TRIGGER_BYTES.with(|c| c.set(usize::MAX / 2)); + let expected_unarmed = if nursery_capped { + ceiling.min(nursery_cap) + } else { + ceiling + }; assert_eq!( effective_next_arena_trigger(), - gc_trigger_absolute_ceiling_bytes(), - "un-armed trigger must clamp to the device ceiling" + expected_unarmed, + "un-armed trigger must clamp to the device ceiling (further to the nursery cap when moving)" ); GC_TRIGGER_ARMED.with(|c| c.set(true)); - let above_ceiling = gc_trigger_absolute_ceiling_bytes() * 3; + let above_ceiling = ceiling * 3; GC_NEXT_TRIGGER_BYTES.with(|c| c.set(above_ceiling)); + let expected_armed = if nursery_capped { + above_ceiling.min(nursery_cap) + } else { + above_ceiling + }; assert_eq!( effective_next_arena_trigger(), - above_ceiling, - "armed triggers above the ceiling are legitimate and must survive" + expected_armed, + "armed triggers above the ceiling survive under legacy pacing and clamp to the nursery cap when moving" ); GC_NEXT_TRIGGER_BYTES.with(|c| c.set(prev_trigger));