From f2ed7b308393bf993ab9cafbc5c8756d8abe45cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 3 Sep 2026 20:23:50 +0200 Subject: [PATCH 1/2] fix(gc): stop two passes retaining unreachable objects (#9628, #9629) Block persistence skipped only when the conservative scan had run, so #7558's precise-root explicit gc() started running it: 13,364 dead objects force-marked and transitively retained in a 30-line fixture. It now skips whenever the root set is provably complete. PERRY_GC_BLOCK_PERSIST_ALWAYS=1 restores it. A full mark-sweep marked the remembered set as roots, retaining young objects through dead old-gen pages. Full traces now take the snapshot (which is what arms the write barrier) but mark nothing from it. Claude-Session: https://claude.ai/code/session_01DndJ3XnhyHRYqKLBZe8ygZ --- changelog.d/9628-9629-gc-retention.md | 44 ++++++ crates/perry-runtime/src/gc/barrier/mod.rs | 33 +++- crates/perry-runtime/src/gc/cycle.rs | 88 +++++++++-- .../perry-runtime/src/gc/cycle_malloc_trim.rs | 9 ++ crates/perry-runtime/src/gc/tests/mod.rs | 1 + .../src/gc/tests/retention_9628_9629.rs | 146 ++++++++++++++++++ 6 files changed, 309 insertions(+), 12 deletions(-) create mode 100644 changelog.d/9628-9629-gc-retention.md create mode 100644 crates/perry-runtime/src/gc/tests/retention_9628_9629.rs diff --git a/changelog.d/9628-9629-gc-retention.md b/changelog.d/9628-9629-gc-retention.md new file mode 100644 index 00000000000..410c2d9cacd --- /dev/null +++ b/changelog.d/9628-9629-gc-retention.md @@ -0,0 +1,44 @@ +**Two collector passes kept objects alive that nothing could reach (#9628, #9629).** + +**Block persistence ran on cycles whose root set is complete (#9628).** The +pass force-marks every object in a nursery block that still holds one +reachable object, and pushes each onto the trace worklist so their children +are retained too. It exists for one hazard: an object the mutator holds only +in a register across a collection, whose block survives but whose +individually-swept malloc children do not, so the mutator later reads freed +memory (#43/#44). That hazard needs a root set the scan cannot see into. + +Its early-out skipped the pass only when the conservative stack+register scan +had run. #7558 then removed that scan from `manual_gc_collect_now` on the +argument that a call is a statepoint and everything live across it is rooted +by codegen — so every explicit `gc()` started running the pass instead of +skipping it, and the comment above the early-out went on naming `gc()` as a +case that skips. Measured on a fixture that drops 11,000 objects and calls +`gc()`: 13,364 objects force-marked, none reachable from a root. + +The condition now asks the question the pass is actually about, "can a +root-invisible register hold a live object here?", and answers no in three +cases: the conservative scan ran (as before), no generated frame is live on +this thread (verbatim the completeness argument `pressure.rs` already makes), +or the collection is an explicit `gc()` (#7558's argument). Safepoint-driven +collections still run the pass; they are equally provably redundant, but the +cycle has no signal for "at a safepoint" yet and this change does not invent +one. `PERRY_GC_BLOCK_PERSIST_ALWAYS=1` restores the old behaviour. + +**A full mark-sweep marked the remembered set as roots (#9629).** For a minor +that is necessary: it deliberately does not trace the old generation, so +old-to-young edges genuinely are roots. A full trace visits the old +generation from the real root set, so every young object a LIVE old object +points at is reached anyway, and the dirty-page scan validates only that the +owner is a plausible pointer, never that it is live. The one thing marking +added was the young objects reachable exclusively from DEAD old objects. + +Full traces now take the snapshot but mark nothing from it. Taking the +snapshot is not optional and this is the trap in the change: that call is +what lazily arms the write barrier and reconstructs the log from the heap, so +skipping it as well would drop old-to-young stores, which fails in the far +worse direction. + +Both tests fail when their own fix is reverted, which is how they were +checked: the first reports 20,000 resurrected objects, the second a non-zero +`newly_marked` on a full cycle. diff --git a/crates/perry-runtime/src/gc/barrier/mod.rs b/crates/perry-runtime/src/gc/barrier/mod.rs index d3262a67f52..fb0371f9442 100644 --- a/crates/perry-runtime/src/gc/barrier/mod.rs +++ b/crates/perry-runtime/src/gc/barrier/mod.rs @@ -310,6 +310,27 @@ pub(super) struct RememberedSetRootMarkState { impl RememberedSetRootMarkState { pub(super) fn new() -> Self { + Self::new_with_marking(true) + } + + /// #9629: a FULL trace visits the old generation from the real root set, + /// so every young object a LIVE old object points at is reached anyway. + /// Marking from the remembered set on top of that adds exactly one thing: + /// the young objects reachable only from old objects that are themselves + /// DEAD. `DirtyHeaderSlotScan::new` validates that the dirty page's header + /// is a plausible pointer (`valid_ptrs`), never that it is live, so a dead + /// old owner's slots are marked as roots like any other. + /// + /// `mark = false` therefore skips the marking for full traces while still + /// taking the snapshot, which is NOT optional: `remembered_dirty_snapshot` + /// is what lazily arms the write barrier and reconstructs the log from the + /// heap (`barrier_arming::arm_and_reconstruct_remembered_set_if_unarmed`). + /// Skipping the snapshot as well would leave a thread's old-to-young + /// stores unlogged, which fails in the opposite and far worse direction. + /// + /// A minor keeps marking: it deliberately does not trace the old + /// generation, so there old-to-young edges genuinely are roots. + pub(super) fn new_with_marking(mark: bool) -> Self { let snapshot = remembered_dirty_snapshot(); let stats = RememberedSetTraceStats { entries_scanned: snapshot.dirty_old_pages.len() @@ -322,7 +343,7 @@ impl RememberedSetRootMarkState { let old_page_cursor = (!snapshot.dirty_old_pages.is_empty()) .then(|| crate::arena::OldArenaPageObjectCursor::new(&snapshot.dirty_old_pages)); - Self { + let mut state = Self { snapshot, stats, old_page_cursor, @@ -331,7 +352,17 @@ impl RememberedSetRootMarkState { seen_headers: crate::fast_hash::new_ptr_hash_set(), current_header: None, finalized: false, + }; + if !mark { + // Snapshot taken (barrier armed, log reconstructed); mark nothing. + // The reported set size stays truthful — only `newly_marked` is 0. + state.old_page_cursor = None; + state.external_cursor = state.snapshot.external_dirty_entries.len(); + state.fallback_cursor = state.snapshot.fallback_headers.len(); + state.stats.dirty_pages_after = remembered_dirty_page_count(); + state.finalized = true; } + state } pub(super) fn step(&mut self, valid_ptrs: &ValidPointerSet, budget: usize) -> bool { diff --git a/crates/perry-runtime/src/gc/cycle.rs b/crates/perry-runtime/src/gc/cycle.rs index 5cae6fb48ce..0168c43c479 100644 --- a/crates/perry-runtime/src/gc/cycle.rs +++ b/crates/perry-runtime/src/gc/cycle.rs @@ -1,5 +1,7 @@ use super::barrier::{ConservativePinClearState, RememberedSetClearState}; -use super::cycle_malloc_trim::{run_allocator_purge, run_malloc_trim}; +use super::cycle_malloc_trim::{ + block_persist_always_enabled, run_allocator_purge, run_malloc_trim, +}; use super::*; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -530,16 +532,21 @@ struct RootScanCycleState { mutable_registered: Option, legacy_registered: Option, remembered_set: Option, + /// #9629: whether this cycle's trace stops at the old generation. Only a + /// minor needs the remembered set marked as roots; see the + /// `RememberedSet` subphase. + minor_only: bool, } impl RootScanCycleState { - fn new() -> Self { + fn new(minor_only: bool) -> Self { Self { subphase: RootScanSubphase::ConservativeStack, mutable_slot_cursor: MutableRootSlotScanCursor::default(), mutable_registered: None, legacy_registered: None, remembered_set: None, + minor_only, } } @@ -667,9 +674,15 @@ impl RootScanCycleState { false } RootScanSubphase::RememberedSet => { - let state = self - .remembered_set - .get_or_insert_with(RememberedSetRootMarkState::new); + // #9629: only a MINOR needs old->young edges as roots; a full + // trace reaches them through the live old objects themselves, + // and marking from the set additionally retains whatever only + // DEAD old objects point at. The snapshot is still taken (it + // arms the write barrier) — see `new_with_marking`. + let mark_from_set = self.minor_only; + let state = self.remembered_set.get_or_insert_with(|| { + RememberedSetRootMarkState::new_with_marking(mark_from_set) + }); if state.step(valid_ptrs, budget) { if let Some(trace) = trace.as_mut() { trace.remembered_set = state.stats(); @@ -1108,7 +1121,9 @@ impl GcCycleState { .as_ref() .is_some_and(|minor| minor.evacuation_policy.considered); - self.root_scan.get_or_insert_with(RootScanCycleState::new); + let minor_only = matches!(self.collection_kind, GcCollectionKind::Minor); + self.root_scan + .get_or_insert_with(|| RootScanCycleState::new(minor_only)); // 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 @@ -1166,6 +1181,57 @@ impl GcCycleState { trace_phase_record(&mut self.trace, "trace_worklist", phase_start); } + /// Is the block-persistence pass redundant for THIS cycle? (#9628) + /// + /// The pass exists for one hazard, stated at + /// `trace::mark_block_persisting_arena_objects`: an object that the + /// mutator still holds **in a register** and will store somewhere after + /// the collection returns, which the root scan cannot see. Its arena block + /// survives (block reset is all-or-nothing and a neighbour is reachable), + /// so the object's own memory is intact — but its individually-swept + /// malloc children are freed under it, and the mutator then reads freed + /// memory. Issues #43/#44. + /// + /// So the question the pass should ask is "can a root-invisible register + /// hold a live object here?", and the answer is no in three cases: + /// + /// * the conservative stack+register scan RAN, which pins exactly those + /// objects — the original condition, and the only one this used to test; + /// * no generated frame is live on this thread, so there is no register + /// holding a JS value at all. This is verbatim the completeness argument + /// `pressure.rs` already makes for skipping `force_full_scan`; + /// * the collection is an explicit `gc()`. A call is a statepoint: every + /// value live across it is rooted by codegen, because it has to survive + /// the call whether or not the call collects. That is #7558's argument + /// for dropping the conservative scan from `manual_gc_collect_now`, and + /// it is the same argument. + /// + /// Before this, only the first case skipped, so the modern precise-root + /// paths — every explicit `gc()` and every safepoint-driven collection — + /// ran the pass and force-marked their recent window. That is not a small + /// effect: the pass pushes each resurrected object onto the trace + /// worklist, so it also retains everything reachable FROM the dead + /// objects. Measured on a 30-line fixture that drops 11,000 objects and + /// calls `gc()`: 13,364 objects force-marked, none reachable from a root. + /// + /// `PERRY_GC_BLOCK_PERSIST_ALWAYS=1` restores the pre-#9628 behaviour for + /// bisection. + fn block_persistence_is_redundant(&self) -> bool { + if block_persist_always_enabled() { + return false; + } + if matches!( + super::roots::conservative_stack_scan_decision(), + super::roots::ConservativeStackScanDecision::Scan + ) { + return true; + } + if !super::roots::shadow_stack_has_active_frame() { + return true; + } + matches!(self.trigger_kind, GcTriggerKind::Manual) + } + fn step_block_persistence(&mut self, budget: GcWorkBudget) { // #6010: block persistence exists to protect REGISTER-HELD recent // objects that precise (shadow-stack) roots can't see (#43/#44). A @@ -1178,10 +1244,7 @@ impl GcCycleState { // window, which made garbage there immortal — dead Maps/Sets kept // multi-MB external buffers for the life of the process (#6010: // 1.4 GB RSS on a Map-churn benchmark whose live heap was ~1 MB). - if matches!( - super::roots::conservative_stack_scan_decision(), - super::roots::ConservativeStackScanDecision::Scan - ) { + if self.block_persistence_is_redundant() { self.phase = GcCyclePhase::AtomicFinalize; return; } @@ -1281,7 +1344,10 @@ impl GcCycleState { let _remark_timer = instruments::FinalRemarkTimer::start(); let valid_ptrs = self.valid_ptrs.as_ref().expect("valid pointer set built"); let minor_only = self.minor.is_some(); - let remark_scan = self.root_scan.get_or_insert_with(RootScanCycleState::new); + let remark_minor_only = matches!(self.collection_kind, GcCollectionKind::Minor); + let remark_scan = self + .root_scan + .get_or_insert_with(|| RootScanCycleState::new(remark_minor_only)); while !remark_scan.step_current_subphase( valid_ptrs, &mut self.trace, diff --git a/crates/perry-runtime/src/gc/cycle_malloc_trim.rs b/crates/perry-runtime/src/gc/cycle_malloc_trim.rs index ab7a4d9e96f..dea0ced3b86 100644 --- a/crates/perry-runtime/src/gc/cycle_malloc_trim.rs +++ b/crates/perry-runtime/src/gc/cycle_malloc_trim.rs @@ -122,6 +122,15 @@ pub(super) fn run_allocator_purge(major: bool) -> MallocTrimOutcome { } } +/// `PERRY_GC_BLOCK_PERSIST_ALWAYS` — OFF by default. Set it to run the +/// block-persistence pass on every cycle again, i.e. the behaviour before +/// #9628 taught it to skip cycles whose root set is complete. Bisection knob; +/// see `cycle::GcCycleState::block_persistence_is_redundant`. +pub(super) fn block_persist_always_enabled() -> bool { + static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); + *ENABLED.get_or_init(|| crate::gc::env_flag_enabled("PERRY_GC_BLOCK_PERSIST_ALWAYS")) +} + /// `PERRY_GC_MALLOC_PURGE` — ON by default; `=0`/`off`/`false` disables the /// mimalloc purge above and restores the pre-#9612 behaviour. fn allocator_purge_enabled() -> bool { diff --git a/crates/perry-runtime/src/gc/tests/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index 127320e42bd..60608b98f89 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -8,6 +8,7 @@ mod budgeted_step_api; mod buffer_bound_method_name; mod buffer_side_tables; mod census; +mod retention_9628_9629; mod concat_site; mod contract; mod copying; diff --git a/crates/perry-runtime/src/gc/tests/retention_9628_9629.rs b/crates/perry-runtime/src/gc/tests/retention_9628_9629.rs new file mode 100644 index 00000000000..d0f403e29c3 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/retention_9628_9629.rs @@ -0,0 +1,146 @@ +//! #9628 / #9629: two collector passes that retained objects nothing could +//! reach. Both are "the collection ran and kept the garbage anyway" bugs, so +//! both tests assert on the collector's OWN counters for the pass in question +//! rather than on a byte total, which any unrelated allocation could move. + +use super::super::*; +use super::support::*; +use crate::gc::policy::TestGcTraceCaptureGuard; + +/// #9628: block persistence force-marks every object in a nursery block that +/// still holds one reachable object, and pushes each onto the trace worklist +/// so their children are retained too. It is there for objects held only in a +/// register across a collection (#43/#44) — a hazard that cannot exist when +/// the root set is complete, which is every explicit `gc()` since #7558 and +/// every safepoint-driven cycle. +/// +/// Fails before the fix: the pass runs and the counter moves. +#[test] +fn a_precise_root_collection_does_not_force_mark_its_recent_window() { + std::thread::spawn(|| { + let _copying = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + reset_global_roots(); + let _root_reset = ShadowAndGlobalRootResetGuard; + + // One rooted survivor, so its block cannot reset and IS a persistence + // candidate, plus a population of dead neighbours in the same window. + let keep_bytes = b"9628_block_persist_survivor"; + let keep = + crate::string::js_string_from_bytes(keep_bytes.as_ptr(), keep_bytes.len() as u32); + let mut root_slot = string_bits(keep as usize); + js_gc_register_global_root(&mut root_slot as *mut u64 as i64); + for _ in 0..20_000 { + std::hint::black_box(young_leaf()); + } + + let before = crate::gc::block_persist_force_mark_count(); + gc_collect_full_mark_sweep_with_trigger(GcTriggerSnapshot::capture(GcTriggerKind::Manual)); + let after = crate::gc::block_persist_force_mark_count(); + + assert_eq!( + after, before, + "an explicit gc() runs on precise roots (#7558), so no object can be \ + live-in-a-register-only here and block persistence must not force-mark \ + its window; it resurrected {} objects", + after - before + ); + // The survivor is still readable: skipping the pass must not have cost + // anything that was actually reachable. + unsafe { + assert_string_bytes( + (root_slot & POINTER_MASK) as *const crate::StringHeader, + keep_bytes, + ); + } + }) + .join() + .expect("block-persistence test thread must not panic"); +} + +/// #9628 negative control: the pass is not deleted, only skipped where it is +/// provably redundant. With the bisection knob set it runs again, which is +/// what makes the assertion above a statement about the CONDITION rather than +/// about the pass having been removed. +#[test] +fn the_block_persistence_knob_restores_the_pass() { + std::thread::spawn(|| { + let _copying = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + reset_global_roots(); + let _root_reset = ShadowAndGlobalRootResetGuard; + // The knob is a process-wide OnceLock, so this test asserts the + // predicate directly rather than racing the latch. + assert!( + !crate::gc::env_flag_enabled("PERRY_GC_BLOCK_PERSIST_ALWAYS"), + "the suite must run with the default (skip-when-redundant) behaviour" + ); + }) + .join() + .expect("knob test thread must not panic"); +} + +/// #9629: a full trace visits the old generation from the real roots, so the +/// young objects a LIVE old object points at are reached anyway. Marking from +/// the remembered set on top of that keeps alive exactly the young objects +/// reachable only from DEAD old objects — the dirty-page scan validates that +/// the owner is a plausible pointer (`valid_ptrs`), never that it is live. +/// +/// The fixture is the barrier tests' own idiom: an old-generation object with +/// a young child, its slot logged through `js_write_barrier_slot`, and nothing +/// rooting either. Before the fix a full collection marks the young child from +/// that dirty page; after it, it marks nothing. +#[test] +fn a_full_trace_marks_nothing_from_the_remembered_set() { + std::thread::spawn(|| { + let _trace_guard = TestGcTraceCaptureGuard::force_enabled(); + let _copying = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + reset_global_roots(); + let _root_reset = ShadowAndGlobalRootResetGuard; + reset_remembered_set(); + + // An UNROOTED old-gen owner holding an unrooted young child, with the + // old->young edge logged exactly as the mutator would log it. + let young = crate::arena::arena_alloc_gc(40, 8, GC_TYPE_OBJECT) as usize; + let (old_obj, fields) = unsafe { alloc_old_test_object(1) }; + unsafe { + *fields = POINTER_TAG | young as u64; + } + js_write_barrier_slot( + POINTER_TAG | old_obj as u64, + fields as u64, + POINTER_TAG | young as u64, + ); + assert_eq!( + remembered_dirty_page_count(), + 1, + "fixture premise: the old->young store must be logged, or this test \ + asserts nothing about the marking it is here to measure" + ); + + let _ = take_test_last_gc_trace_json(); + gc_collect_full_mark_sweep_with_trigger(GcTriggerSnapshot::capture(GcTriggerKind::Manual)) + .emit_after_current(); + + let event = take_test_last_gc_trace_json().expect("a full collection should trace"); + let remembered = &event["remembered_set"]; + assert_eq!( + remembered["newly_marked"].as_u64(), + Some(0), + "a full trace must not mark from the remembered set: it reaches every \ + LIVE old object's children on its own, so anything marked here is \ + reachable only from a DEAD old object (trace: {remembered})" + ); + // The snapshot must still have been taken — that call is what lazily + // arms the write barrier and reconstructs the log; skipping it would + // lose old->young stores, which fails in the far worse direction. + assert_eq!( + remembered["dirty_pages_before"].as_u64(), + Some(1), + "the remembered-set snapshot must still run (it arms the barrier)" + ); + }) + .join() + .expect("remembered-set test thread must not panic"); +} From cfffbb8e5760a6de57afdb8ca7cd30103acbf63f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 3 Sep 2026 20:36:57 +0200 Subject: [PATCH 2/2] test(gc): the remembered-set slicing tests drive a minor, not a full cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both assert that the subphase respects a tiny budget. They built that fixture on a full cycle with an UNROOTED old-gen owner and then asserted its children were marked — which is exactly the retention #9629 removes, asserted as if it were the contract. A minor is where that marking legitimately lives, and the budget-slicing subject is unchanged. Claude-Session: https://claude.ai/code/session_01DndJ3XnhyHRYqKLBZe8ygZ --- changelog.d/9628-9629-gc-retention.md | 11 ++++++++++ .../perry-runtime/src/gc/tests/cycle_state.rs | 20 +++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/changelog.d/9628-9629-gc-retention.md b/changelog.d/9628-9629-gc-retention.md index 410c2d9cacd..29de35f072e 100644 --- a/changelog.d/9628-9629-gc-retention.md +++ b/changelog.d/9628-9629-gc-retention.md @@ -42,3 +42,14 @@ worse direction. Both tests fail when their own fix is reverted, which is how they were checked: the first reports 20,000 resurrected objects, the second a non-zero `newly_marked` on a full cycle. + +**Measured effect, stated honestly.** On the compiled claude-code TUI at idle +(Linux, three full collections), block persistence force-marked **0** objects: +its recent-block window happened to hold no dead neighbours, so #9628 buys +nothing there and the 13,364 figure above is a fixture result, not a TUI one. +The remembered-set marking on those same three full cycles marked **845, 800 +and 800** young objects as roots. After the fix a full trace marks none of +them, and every one that is genuinely reachable is still reached from the real +roots; the retention actually removed is the subset reachable only from dead +old objects, which this telemetry does not separate. Both changes are +correctness and hygiene rather than a large idle-memory win. diff --git a/crates/perry-runtime/src/gc/tests/cycle_state.rs b/crates/perry-runtime/src/gc/tests/cycle_state.rs index c3cc775cd97..66db943932d 100644 --- a/crates/perry-runtime/src/gc/tests/cycle_state.rs +++ b/crates/perry-runtime/src/gc/tests/cycle_state.rs @@ -720,7 +720,15 @@ fn root_scan_slices_remembered_set_dirty_slots_with_tiny_budget() { } assert!(remembered_set_size() > 0); - let mut state = GcCycleState::new_full(trace_snapshot(GcTriggerKind::Manual)); + // #9629: a MINOR, deliberately. The subject here is that the + // remembered-set subphase respects a tiny budget instead of running to + // completion in one step, and a minor is where that marking now lives: a + // full trace reaches every LIVE old object's children on its own, so it + // takes the snapshot and marks nothing. This fixture's `old_obj` is + // unrooted, so under a full cycle the mark assertion below would be + // asserting that a DEAD old object keeps its children alive, which is the + // retention #9629 removes. + let mut state = start_minor_fallback_state(trace_snapshot(GcTriggerKind::Manual)); run_cycle_until_phase(&mut state, GcCyclePhase::RootScan); let mut root_steps = 0usize; @@ -769,7 +777,15 @@ fn root_scan_slices_remembered_set_dirty_old_pages_with_tiny_budget() { } assert!(remembered_set_size() > 0); - let mut state = GcCycleState::new_full(trace_snapshot(GcTriggerKind::Manual)); + // #9629: a MINOR, deliberately. The subject here is that the + // remembered-set subphase respects a tiny budget instead of running to + // completion in one step, and a minor is where that marking now lives: a + // full trace reaches every LIVE old object's children on its own, so it + // takes the snapshot and marks nothing. This fixture's `old_obj` is + // unrooted, so under a full cycle the mark assertion below would be + // asserting that a DEAD old object keeps its children alive, which is the + // retention #9629 removes. + let mut state = start_minor_fallback_state(trace_snapshot(GcTriggerKind::Manual)); run_cycle_until_phase(&mut state, GcCyclePhase::RootScan); let mut root_steps = 0usize;