Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions crates/perry-runtime/src/gc/tests/cycle_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,76 @@ fn gap_born_child_stored_between_finalize_and_sweep_survives() {
crate::object::test_clear_overflow_fields_root();
}

/// Regression (#6495): the trace must visit EVERY overflow slot of a live
/// object, not the subset its layout mask claims. The per-object slot mask
/// is maintained by `layout_note_slot` at store time, but not every
/// overflow write path notes (GC owner moves merge entries via
/// `merge_overflow_fields` with no notes) — a stale SIDE_MASK then hides
/// pointer-bearing slots from the trace, and their referents are swept
/// while referenced. Observed at bundle scale as masks capped at bit 48
/// with live NaN-boxed pointers sitting at slots 49..63.
#[test]
fn overflow_slots_beyond_layout_mask_are_traced() {
let _guard = CopyingNurseryTestGuard::new(1);
let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers();

let (owner, _fields) = unsafe { alloc_nursery_test_object(1) };
js_shadow_slot_set(0, ptr_bits(owner as usize));

// Build a usable SIDE_MASK layout claiming slots 0..=48 as the complete
// pointer set. A pointer note only creates a side mask from the
// POINTER_FREE state (notes on an UNKNOWN object leave it UNKNOWN =
// conservative full visit), so first rebuild the layout from the
// object's single non-pointer inline slot.
unsafe {
let zero: u64 = 0;
crate::gc::layout_rebuild_from_slots(owner as *mut u8, &zero as *const u64, 1);
}
let dummy = young_leaf();
for i in 0..49 {
crate::gc::layout_note_slot(owner as usize, i, string_bits(dummy));
}
// The bug path: an overflow write that never runs `layout_note_slot`.
// Slot 50 holds the ONLY reference to a live string; the mask does not
// know about it.
let child = young_leaf();
let mut values = vec![crate::value::TAG_UNDEFINED; 51];
values[50] = string_bits(child);
crate::object::test_seed_overflow_fields_vec(owner as usize, values);

// Age the owner/child block out of the block-persistence window (that
// pass would otherwise force-mark the child as a register-holding
// candidate and mask the missing trace).
let aged_from = crate::arena::general_block_count();
let mut filler_blocks = 0usize;
while filler_blocks < 7 {
for _ in 0..64 {
let _ = unsafe { crate::arena::arena_alloc_gc(4096, 8, GC_TYPE_STRING) };
}
filler_blocks = crate::arena::general_block_count().saturating_sub(aged_from);
}

let mut state = GcCycleState::new_full(trace_snapshot(GcTriggerKind::Manual));
run_cycle_until_phase(&mut state, GcCyclePhase::Sweep);
Comment on lines +905 to +923

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Directly verify the object field-slot walker too.

The final marking assertion can pass solely through scan_overflow_fields_roots_mut, because overflow fields are independently registered as mutable roots. Therefore, reverting visit_overflow_field_slots_mut to its mask-based behavior would not necessarily fail this test.

Add a direct assertion after seeding:

Proposed additional assertion
     crate::object::test_seed_overflow_fields_vec(owner as usize, values);
+
+    let mut visited_slots = 0;
+    crate::object::visit_overflow_field_slots_mut(owner as usize, |_| {
+        visited_slots += 1;
+    });
+    assert_eq!(
+        visited_slots, 51,
+        "the object trace must visit every live overflow slot"
+    );

This independently protects both walkers required by the regression objective.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let child = young_leaf();
let mut values = vec![crate::value::TAG_UNDEFINED; 51];
values[50] = string_bits(child);
crate::object::test_seed_overflow_fields_vec(owner as usize, values);
// Age the owner/child block out of the block-persistence window (that
// pass would otherwise force-mark the child as a register-holding
// candidate and mask the missing trace).
let aged_from = crate::arena::general_block_count();
let mut filler_blocks = 0usize;
while filler_blocks < 7 {
for _ in 0..64 {
let _ = unsafe { crate::arena::arena_alloc_gc(4096, 8, GC_TYPE_STRING) };
}
filler_blocks = crate::arena::general_block_count().saturating_sub(aged_from);
}
let mut state = GcCycleState::new_full(trace_snapshot(GcTriggerKind::Manual));
run_cycle_until_phase(&mut state, GcCyclePhase::Sweep);
let child = young_leaf();
let mut values = vec![crate::value::TAG_UNDEFINED; 51];
values[50] = string_bits(child);
crate::object::test_seed_overflow_fields_vec(owner as usize, values);
let mut visited_slots = 0;
crate::object::visit_overflow_field_slots_mut(owner as usize, |_| {
visited_slots += 1;
});
assert_eq!(
visited_slots, 51,
"the object trace must visit every live overflow slot"
);
// Age the owner/child block out of the block-persistence window (that
// pass would otherwise force-mark the child as a register-holding
// candidate and mask the missing trace).
let aged_from = crate::arena::general_block_count();
let mut filler_blocks = 0usize;
while filler_blocks < 7 {
for _ in 0..64 {
let _ = unsafe { crate::arena::arena_alloc_gc(4096, 8, GC_TYPE_STRING) };
}
filler_blocks = crate::arena::general_block_count().saturating_sub(aged_from);
}
let mut state = GcCycleState::new_full(trace_snapshot(GcTriggerKind::Manual));
run_cycle_until_phase(&mut state, GcCyclePhase::Sweep);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/gc/tests/cycle_state.rs` around lines 750 - 768,
After seeding the overflow fields with test_seed_overflow_fields_vec, directly
invoke the object field-slot walker visit_overflow_field_slots_mut and assert
that it visits the expected child slot/value. Keep the existing cycle-marking
assertion unchanged so the test independently verifies both the walker and
scan_overflow_fields_roots_mut paths.

unsafe {
let header = header_from_user_ptr(child as *const u8);
assert_ne!(
(*header).gc_flags & GC_FLAG_MARKED,
0,
"a pointer in an overflow slot beyond the layout mask was not \
traced: its referent is about to be swept live"
);
}
run_cycle_in_single_unit_steps(&mut state);
let _ = state.take_outcome().expect("cycle should complete");
crate::object::test_clear_overflow_fields_root();

// Reclaim the filler blocks so later bounded-step tests aren't slowed.
let mut cleanup = GcCycleState::new_full(trace_snapshot(GcTriggerKind::Manual));
run_cycle_in_single_unit_steps(&mut cleanup);
let _ = cleanup.take_outcome();
}

#[test]
fn full_atomic_finalize_slices_barrier_seed_drain_with_tiny_budget() {
let _guard = CopyingNurseryTestGuard::new(1);
Expand Down
41 changes: 24 additions & 17 deletions crates/perry-runtime/src/object/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1012,13 +1012,9 @@ pub fn scan_overflow_fields_roots_mut(visitor: &mut crate::gc::RuntimeRootVisito
if visitor.visit_metadata_usize_slot(&mut new_owner) {
moved.push((owner, new_owner));
}
if crate::gc::layout_visit_pointer_slots_for_user(new_owner, fields.len(), |i| {
if let Some(val_bits) = fields.get_mut(i) {
visitor.visit_nanbox_u64_slot(val_bits);
}
}) {
continue;
}
// #6495: same contract as `visit_overflow_field_slots_mut` — the
// layout mask under-reports overflow pointer slots on paths that
// skip `layout_note_slot`, so scan every slot.
for val_bits in fields.iter_mut() {
visitor.visit_nanbox_u64_slot(val_bits);
}
Expand Down Expand Up @@ -1049,17 +1045,18 @@ pub(crate) fn visit_overflow_field_slots_mut(owner: usize, mut visit: impl FnMut
if fields.is_empty() {
return Vec::new();
}
let mut slots = Vec::new();
// #6495: visit EVERY overflow slot — never the layout-mask subset.
// The per-object slot mask is maintained by `layout_note_slot` at
// store time, but not every overflow write path notes (GC owner
// moves merge entries via `merge_overflow_fields` with no notes), so
// a usable-looking SIDE_MASK can under-report pointer-bearing
// overflow slots; the trace would then skip live children and the
// sweep frees them while referenced. The Vec's length is the live
// overflow region, and objects with large overflow populations are
// in UNKNOWN layout state in practice (dynamic-shape stores degrade
// the layout), so the mask bought little here.
let mut slots = Vec::with_capacity(fields.len());
let base = fields.as_ptr() as *mut u64;
if crate::gc::layout_visit_pointer_slots_for_user(owner, fields.len(), |i| {
if i < fields.len() {
unsafe {
slots.push(base.add(i));
}
}
}) {
return slots;
}
for i in 0..fields.len() {
unsafe {
slots.push(base.add(i));
Expand Down Expand Up @@ -1281,6 +1278,16 @@ pub(crate) fn debug_overflow_entry_len(owner: usize) -> Option<usize> {
OVERFLOW_FIELDS.with(|m| m.borrow().get(&owner).map(|v| v.len()))
}

#[cfg(test)]
pub(crate) fn test_seed_overflow_fields_vec(owner: usize, values: Vec<u64>) {
OVERFLOW_FIELDS.with(|m| {
m.borrow_mut().insert(owner, values);
});
OVERFLOW_LAST.with(|c| unsafe {
*c.get() = (0, std::ptr::null_mut());
});
}

#[cfg(test)]
pub(crate) fn test_clear_overflow_fields_root() {
OVERFLOW_FIELDS.with(|m| m.borrow_mut().clear());
Expand Down
Loading