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 7f54ec8f64..030ab96150 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 @@ -720,3 +720,60 @@ fn test_copying_minor_old_to_malloc_nursery_grandchild_survives() { not left dangling in reset from-space" ); } + +// #6186 (2026-07-09 GC audit): Date cells are movable. The flag only gates +// old-page defrag, but any move (incl. copied-minor evacuation) runs the +// ExoticExpandoOwner hook — so a Date's `d.foo = …` expando properties must +// migrate with the cell, and the cell's `ts` must survive, exactly like the +// movable Promise precedent. +#[test] +fn test_movable_date_evacuation_migrates_expando_and_preserves_ts() { + // Date cells are movable (#6186). The flag only gates old-page defrag, but + // any move (incl. copied-minor evacuation, via `gc_type_after_payload_move`) + // runs the ExoticExpandoOwner hook — so a Date's `d.foo = ...` expandos must + // migrate with the cell and its `ts` must survive, per the movable-Promise + // precedent. + assert!( + crate::gc::gc_type_is_movable(crate::gc::GC_TYPE_DATE_CELL), + "GC_TYPE_DATE_CELL must be movable after #6186" + ); + + let _guard = CopyingNurseryTestGuard::new(1); + let ts = 1_234_567_890.5_f64; + let date_addr = (crate::date::alloc_date_cell(ts).to_bits() & POINTER_MASK) as usize; + assert!(crate::arena::pointer_in_nursery(date_addr)); + + let expando_val = f64::from_bits(crate::value::JSValue::int32(42).bits()); + crate::object::exotic_expando::test_seed_exotic_expando_entry( + date_addr, + "tag", + expando_val.to_bits(), + ); + assert!(crate::object::exotic_expando::test_exotic_expando_entry_exists(date_addr)); + js_shadow_slot_set(0, ptr_bits(date_addr)); + + let _ = gc_collect_minor(); + + let new_addr = (js_shadow_slot_get(0) & POINTER_MASK) as usize; + assert_ne!(new_addr, 0, "rooted Date must survive the copied minor"); + assert_ne!( + new_addr, date_addr, + "the Date cell must have been evacuated (moved)" + ); + + // ts preserved through the move; still a Date at the new address. + let moved_ts = unsafe { (*(new_addr as *const crate::date::DateCell)).ts }; + assert_eq!(moved_ts, ts, "Date ts must survive evacuation"); + assert!(crate::date::is_date_cell_addr(new_addr)); + + // Expando migrated to the new address (ExoticExpandoOwner move hook fired) + // and does not linger at the stale old address. + assert!( + crate::object::exotic_expando::test_exotic_expando_entry_exists(new_addr), + "expando must migrate to the evacuated Date's new address" + ); + assert!( + !crate::object::exotic_expando::test_exotic_expando_entry_exists(date_addr), + "expando must not remain at the stale old address" + ); +} diff --git a/crates/perry-runtime/src/gc/types.rs b/crates/perry-runtime/src/gc/types.rs index 1f2e3afb2b..92b5c9097a 100644 --- a/crates/perry-runtime/src/gc/types.rs +++ b/crates/perry-runtime/src/gc/types.rs @@ -466,16 +466,29 @@ pub(super) static GC_TYPE_INFO_BY_ID: [Option; MALLOC_KIND_BUCKET_CO true, GcRewriteDescriptorKind::Leaf, GcLayoutSlotKind::None, - // Non-movable: a Date is referenced by a NaN-boxed pointer kept in a - // plain f64/DOUBLE local that codegen does NOT shadow-root. The - // conservative stack scan keeps it alive; keeping the address stable - // means that un-rooted pointer never goes stale across a GC move. - false, + // Movable (#6186, 2026-07-09 GC audit). Directly analogous to + // `GC_TYPE_PROMISE` above: a pointer-free arena object with + // address-keyed exotic-expando properties, rekeyed on relocation by + // the `ExoticExpandoOwner` move hook. The `movable` flag only gates + // OLD-PAGE DEFRAG (`gc_type_is_movable`, oldgen.rs) — the nursery + // copied-minor already evacuates eden objects regardless of it — so a + // promoted, long-lived Date could pin its old-gen page from defrag. + // Safety: old-page defrag runs only inside MOVING collections (the + // precise pump-boundary safepoint, where the JS stack is unwound so no + // un-shadow-rooted Date f64 local is live), the SAME collections at + // which the nursery already evacuates Dates. Any live reference is a + // traced heap slot the copy pass rewrites; a Date pointer parked in an + // un-shadow-rooted local only exists mid-frame, never at a safepoint — + // the identical invariant that already makes movable Promises sound. + true, GcExternalBytePolicy::None, GcLargeObjectPolicy::NotApplicable, // pointer_free: the single `ts` slot is a raw f64, never a JSValue. true, - GcMoveHookKind::None, + // `d.foo = …` expandos live in `object::exotic_expando` keyed by the + // Date address; rekey that entry when the cell relocates (mirrors + // Promise). Without this a moved Date loses its expando properties. + GcMoveHookKind::ExoticExpandoOwner, GcRewriteHookKind::None, GcFinalizeHookKind::None, )),