From f1e048b188360bbf638d4a3918a8bd0d15215960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 23:51:39 +0200 Subject: [PATCH 1/2] fix(gc): preserve young logs across promotion rollback --- .../src/gc/tests/promote_in_place.rs | 50 +++++++++++++++++++ crates/perry-runtime/src/gc/young_log.rs | 30 +++++++---- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/crates/perry-runtime/src/gc/tests/promote_in_place.rs b/crates/perry-runtime/src/gc/tests/promote_in_place.rs index 8b4dc5b516..be38b8f59e 100644 --- a/crates/perry-runtime/src/gc/tests/promote_in_place.rs +++ b/crates/perry-runtime/src/gc/tests/promote_in_place.rs @@ -788,6 +788,56 @@ fn a_first_cycle_attempt_that_its_own_trace_refutes_rolls_back_and_evacuates() { ); } +/// A speculative promotion temporarily gives every nursery block old-gen +/// semantics. Young-entry root logs must nevertheless survive an attempt that +/// is rolled back: the evacuation retry still depends on them to find values +/// reachable only through a side table. +#[test] +fn first_cycle_rollback_preserves_young_side_table_roots_for_the_retry() { + let _guard = CopyingNurseryTestGuard::new(0); + let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _promote = InPlacePromotionTestGuard::enabled(1000); + clear_young_survival_for_tests(); + // Exhaust the blind-promotion budget so the speculative attempt performs + // the mutable-root mark/rewrite walks that consume young logs. + seed_untraced_promoted_bytes_for_tests(usize::MAX); + gc_register_mutable_root_scanner(crate::closure::scan_closure_dynamic_props_roots_mut); + + let owner = crate::arena::arena_alloc_gc_old( + std::mem::size_of::(), + std::mem::align_of::(), + GC_TYPE_CLOSURE, + ) as usize; + unsafe { init_test_closure(owner as *mut u8) }; + // Arm the remembered-set reconstruction before publishing the value, then + // remove the ordinary old-object edge so the young log is the only root. + let _ = remembered_dirty_snapshot(); + let value = young_leaf(); + crate::closure::closure_set_dynamic_prop(owner, "memo", f64::from_bits(string_bits(value))); + remembered_set_clear(); + for _ in 0..64 { + let _garbage = young_leaf(); + } + + let attempts_before = first_cycle_promotion_attempts(); + let rollbacks_before = first_cycle_promotion_rollbacks(); + let trace = collect_minor_trace(GcTriggerKind::Direct); + + assert_copied_minor_trace(&trace, true, CopiedMinorFallbackReason::None, false); + assert_eq!(first_cycle_promotion_attempts() - attempts_before, 1); + assert_eq!(first_cycle_promotion_rollbacks() - rollbacks_before, 1); + let bits = crate::closure::closure_get_own_dynamic_prop(owner, "memo") + .expect("the old owner must retain its side-table value") + .to_bits(); + let value_after = (bits & POINTER_MASK) as usize; + assert_eq!(bits & TAG_MASK, STRING_TAG); + assert_ne!( + value_after, value, + "the retry must evacuate a value reachable only through the young log" + ); + assert!(crate::arena::pointer_in_nursery(value_after)); +} + // --------------------------------------------------------------------------- // #7913 interaction: old-page relocation vs a still-DESCRIBED promoted run // --------------------------------------------------------------------------- diff --git a/crates/perry-runtime/src/gc/young_log.rs b/crates/perry-runtime/src/gc/young_log.rs index 368d146149..9f6d43b22f 100644 --- a/crates/perry-runtime/src/gc/young_log.rs +++ b/crates/perry-runtime/src/gc/young_log.rs @@ -70,7 +70,7 @@ use std::cell::RefCell; use super::GC_HEADER_SIZE; -use crate::arena::HeapGeneration; +use crate::arena::HeapSpace; use crate::value::{BIGINT_TAG, POINTER_MASK, POINTER_TAG, STRING_TAG, TAG_MASK}; /// Keys of side-table entries that may hold a pointer a minor can act on. @@ -179,21 +179,29 @@ impl YoungLog { /// Can a minor-scoped pass act on the object at `addr`? /// -/// `false` is authoritative for the old generation only: an old object is -/// neither moved nor swept by any minor, and it never becomes young again. -/// Everything a minor moves, marks-through or sweeps answers `true` — -/// nursery (eden + both survivor halves), longlived, and malloc-GC objects. -/// A non-heap word (handle id, foreign-thread address, integer) answers -/// `false` through the exact malloc-registry probe, never a header sniff. +/// `false` is authoritative for ordinary old-generation space only: an old +/// object is neither moved nor swept by any minor, and it never becomes young +/// again. `PromotedYoung` remains relevant while an in-place promotion is in +/// flight because a speculative first-cycle attempt can still roll its retag +/// back and evacuate those objects. Dropping their keys during the attempt +/// would make the retry skip live side-table roots. Everything else a minor +/// moves, marks-through or sweeps answers `true` — nursery (eden + both +/// survivor halves), longlived, and malloc-GC objects. A non-heap word (handle +/// id, foreign-thread address, integer) answers `false` through the exact +/// malloc-registry probe, never a header sniff. #[inline] pub(crate) fn addr_is_minor_relevant(addr: usize) -> bool { if addr == 0 { return false; } - match crate::arena::classify_heap_generation(addr) { - HeapGeneration::Old => false, - HeapGeneration::Nursery | HeapGeneration::Longlived => true, - HeapGeneration::Unknown => { + match crate::arena::classify_heap_space(addr) { + HeapSpace::Old => false, + HeapSpace::NurseryEden + | HeapSpace::Survivor0 + | HeapSpace::Survivor1 + | HeapSpace::Longlived + | HeapSpace::PromotedYoung => true, + HeapSpace::Unknown => { addr > GC_HEADER_SIZE && super::malloc::gc_malloc_header_is_tracked( (addr - GC_HEADER_SIZE) as *const super::GcHeader, From ab2e6510c6d0f06e7f8d88a46e2663e0e5c01ee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 23:53:37 +0200 Subject: [PATCH 2/2] docs: add changelog for promotion rollback fix --- changelog.d/9924-gc-promotion-rollback-young-logs.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/9924-gc-promotion-rollback-young-logs.md diff --git a/changelog.d/9924-gc-promotion-rollback-young-logs.md b/changelog.d/9924-gc-promotion-rollback-young-logs.md new file mode 100644 index 0000000000..ed0ef206c8 --- /dev/null +++ b/changelog.d/9924-gc-promotion-rollback-young-logs.md @@ -0,0 +1,5 @@ +### Fixed + +- Preserve side-table young-root logs when the first copying collection + abandons speculative in-place promotion, so the evacuation retry does not + lose live objects or corrupt full applications during startup.