From 30ce16585ddffb6be825d9543fd689722c27326c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 26 Jul 2026 00:04:38 +0200 Subject: [PATCH 1/7] =?UTF-8?q?perf(runtime):=20#6812=20=E2=80=94=20object?= =?UTF-8?q?-owned=20overflow=20storage=20(spill=20buffer)=20replaces=20the?= =?UTF-8?q?=20TLS=20side=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Values past the inline alloc_limit move from ObjectHotTables::overflow_fields (thread-local PtrHashMap keyed by object address: TLS fetch + RefCell + hash probe per access, ~250B map+Vec per wide object, visited/re-keyed/finalized by every GC cycle) into a GC_TYPE_ARRAY buffer hung off the object's ObjectMeta record. Reads become two dependent loads; GC integration is structural — the buffer is a traced child edge (object → meta → buffer → elements; ObjectMeta rewrite arm visits the new slot like prototype), so marking, evacuation rewriting, owner moves and death ride the ordinary object graph. The buffer is allocated length == capacity with TAG_HOLE slots, so in-range js_array_set never grows/forwards it. Absolute field indexing and hole/undefined absence semantics mirror the retired Vec, so every consumer (delete-compaction included) works unchanged through overflow_get/overflow_set. Legacy path kept one release behind PERRY_OBJECT_SPILL=0 for bisection; its GC hooks no-op on the empty map. Claude-Session: https://claude.ai/code/session_01QJ5mwMDPc63tNLAFPdthAG --- crates/perry-runtime/src/gc/layout.rs | 3 + crates/perry-runtime/src/object/mod.rs | 126 +++++++++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index 32e7748fb4..e0100a2564 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -1515,6 +1515,9 @@ pub(super) unsafe fn visit_gc_rewrite_slot_descriptors( // 0-unset sentinels, which the slot visitor ignores). let meta = user_ptr as *mut crate::object::ObjectMeta; visit(fixed_slot(&mut (*meta).prototype as *mut u64)); + // #6812: the object-owned overflow buffer is a raw-pointer child + // edge (0 = none), traced and rewritten exactly like `prototype`. + visit(fixed_slot(&mut (*meta).spill as *mut u64)); } GcRewriteDescriptorKind::Leaf => {} } diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index e15034cd1b..331068bd65 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -395,12 +395,124 @@ fn keys_index_insert( // obtaining `&mut Vec` and caching its address. // (Storage: `ObjectHotTables::overflow_last`.) +// --------------------------------------------------------------------------- +// #6812: object-owned overflow storage ("spill"). +// +// Default-on replacement for the thread-local `overflow_fields` side table: +// values past the inline alloc_limit live in a `GC_TYPE_ARRAY` buffer hung +// off the object's `ObjectMeta` record ([`ObjectMeta::spill`]). Reads are two +// dependent loads instead of a TLS fetch + RefCell + PtrHashMap probe, and +// GC integration is structural — the buffer is a traced child edge (object → +// meta → buffer → elements), so marking, evacuation rewriting, owner moves, +// and death all ride the ordinary object graph. The legacy side-table code +// below stays compiled for one release as a bisection escape hatch +// (`PERRY_OBJECT_SPILL=0`/`off`/`false`); its GC hooks are no-ops while the +// map stays empty. + +#[inline] +fn object_spill_enabled() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| { + !matches!( + std::env::var("PERRY_OBJECT_SPILL").as_deref(), + Ok("0") | Ok("off") | Ok("false") + ) + }) +} + +fn spill_get(obj_ptr: usize, field_index: usize) -> Option { + unsafe { + let obj = obj_ptr as *mut ObjectHeader; + if (*obj).meta.is_null() { + return None; + } + let spill = (*(*obj).meta).spill as *const crate::array::ArrayHeader; + if spill.is_null() || field_index >= (*spill).length as usize { + return None; + } + let bits = crate::array::js_array_get(spill, field_index as u32).bits(); + // Never-written positions are TAG_HOLE from allocation (or + // TAG_UNDEFINED via the legacy-parity fillers); both report as + // absent, matching the side-table Vec's TAG_UNDEFINED semantics. + (bits != crate::value::TAG_UNDEFINED && bits != crate::value::TAG_HOLE).then_some(bits) + } +} + +fn spill_set(obj_ptr: usize, field_index: usize, vbits: u64) { + unsafe { + let obj = obj_ptr as *mut ObjectHeader; + // Learn the class's true width so FUTURE instances allocate it + // inline (same hook as the legacy path). + note_learned_inline_fields((*obj).class_id, (field_index as u32).saturating_add(1)); + // Root the owner: meta/buffer allocation below can trigger a moving + // minor GC. Reload through the handle after every allocation. + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_handle = scope.root_raw_mut_ptr(obj); + object_meta_ensure(obj); + let obj = obj_handle.get_raw_mut_ptr::(); + let meta = (*obj).meta; + let spill = (*meta).spill as *mut crate::array::ArrayHeader; + let needed = field_index + 1; + if spill.is_null() || ((*spill).capacity as usize) < needed { + let new_cap = u32::try_from(needed.next_power_of_two().max(8)).unwrap_or(u32::MAX); + // length == capacity and every slot TAG_HOLE from birth, so the + // GC element range covers the whole buffer and in-range + // `js_array_set` can never trigger array growth/forwarding — + // `meta.spill` always points at the live block (GC rewrites it + // as a child edge on evacuation). + let new_spill = crate::array::js_array_alloc_with_length(new_cap); + let obj = obj_handle.get_raw_mut_ptr::(); + let meta = (*obj).meta; + let old = (*meta).spill as *const crate::array::ArrayHeader; + if !old.is_null() { + let old_len = (*old).length as usize; + let elements = (old as *const u8) + .add(std::mem::size_of::()) + as *const u64; + for i in 0..old_len { + let bits = *elements.add(i); + if bits != crate::value::TAG_HOLE && bits != crate::value::TAG_UNDEFINED { + // Barriered + layout-aware store; in range by + // construction (old_len <= old cap < new_cap). + crate::array::js_array_set( + new_spill, + i as u32, + crate::value::JSValue::from_bits(bits), + ); + } + } + } + // GC_STORE_AUDIT(BARRIERED): meta-record slot store + barrier, + // mirroring the `header.meta` edge install. + (*meta).spill = new_spill as u64; + crate::gc::runtime_write_barrier_slot( + meta as usize, + &(*meta).spill as *const _ as usize, + new_spill as u64, + ); + } + let obj = obj_handle.get_raw_mut_ptr::(); + let meta = (*obj).meta; + let spill = (*meta).spill as *mut crate::array::ArrayHeader; + // No owner-side layout note: the value lives in the buffer, whose + // own layout/barrier bookkeeping `js_array_set` maintains. + crate::array::js_array_set( + spill, + field_index as u32, + crate::value::JSValue::from_bits(vbits), + ); + } +} + /// Read the u64 bits stored at `field_index` for `obj`, or `None` if absent. /// Positions never written are stored as `TAG_UNDEFINED`; this helper reports /// them as `None` so callers can return JS `undefined` uniformly with the /// "no Vec entry at all" case. #[inline] fn overflow_get(obj_ptr: usize, field_index: usize) -> Option { + if object_spill_enabled() { + return spill_get(obj_ptr, field_index); + } crate::state::state() .object_hot .overflow_fields @@ -482,6 +594,9 @@ pub(crate) fn learned_inline_field_count(class_id: u32) -> u32 { /// overflow slots fill in sequence. #[inline] fn overflow_set(obj_ptr: usize, field_index: usize, vbits: u64) { + if object_spill_enabled() { + return spill_set(obj_ptr, field_index, vbits); + } // Learn the class's true width so FUTURE instances allocate it inline. unsafe { let hdr = obj_ptr as *const ObjectHeader; @@ -1625,6 +1740,16 @@ pub struct ObjectMeta { /// it for prototype divergence made every typed-layout object appear to /// have a custom prototype. pub flags: u64, + /// #6812: object-owned overflow storage — a `GC_TYPE_ARRAY` buffer + /// (`*mut ArrayHeader` bits, 0 = none) holding the NaN-boxed values of + /// properties whose field index is at or past the inline alloc_limit, + /// indexed by ABSOLUTE field index (the inline region's entries stay + /// hole/undefined, mirroring the retired side-table Vec's fillers). + /// A traced child edge exactly like `prototype`: the buffer lives and + /// moves with this record, which lives and moves with its owner — no + /// pointer-keyed side state, no owner re-keying on evacuation, no + /// per-object finalization. + pub spill: u64, } pub(crate) const OBJECT_META_FLAG_PROTO_OVERRIDE: u64 = 1; @@ -1658,6 +1783,7 @@ pub(crate) unsafe fn object_meta_ensure(obj: *mut ObjectHeader) -> *mut ObjectMe (*meta).attr_key_bits = 0; (*meta).accessor_key_bits = 0; (*meta).flags = 0; + (*meta).spill = 0; // GC_STORE_AUDIT(BARRIERED): meta-record edge is a header-slot store // followed by an object-slot barrier, mirroring `set_object_keys_array`. (*obj).meta = meta; From fdb8900c33a5c20cc0794d485ba69e53ee671c7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 26 Jul 2026 00:17:15 +0200 Subject: [PATCH 2/7] =?UTF-8?q?perf(runtime):=20scope-free=20spill=20fast?= =?UTF-8?q?=20path=20=E2=80=94=20in-capacity=20stores=20cannot=20allocate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01QJ5mwMDPc63tNLAFPdthAG --- crates/perry-runtime/src/object/mod.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 331068bd65..4567e25d52 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -444,6 +444,32 @@ fn spill_set(obj_ptr: usize, field_index: usize, vbits: u64) { // Learn the class's true width so FUTURE instances allocate it // inline (same hook as the legacy path). note_learned_inline_fields((*obj).class_id, (field_index as u32).saturating_add(1)); + // Hot path: meta and buffer already exist with capacity — the + // in-range barriered store cannot allocate or move anything, so no + // handle scope is needed. This is every write after the first to a + // given width (e.g. round-robin updates across an object array). + let meta = (*obj).meta; + if !meta.is_null() { + let spill = (*meta).spill as *mut crate::array::ArrayHeader; + if !spill.is_null() && ((*spill).capacity as usize) > field_index { + crate::array::js_array_set( + spill, + field_index as u32, + crate::value::JSValue::from_bits(vbits), + ); + return; + } + } + spill_set_slow(obj_ptr, field_index, vbits); + } +} + +/// Allocation path: ensure the meta record and a buffer wide enough for +/// `field_index`, then store. Roots the owner across the allocations. +#[cold] +fn spill_set_slow(obj_ptr: usize, field_index: usize, vbits: u64) { + unsafe { + let obj = obj_ptr as *mut ObjectHeader; // Root the owner: meta/buffer allocation below can trigger a moving // minor GC. Reload through the handle after every allocation. let scope = crate::gc::RuntimeHandleScope::new(); From a6736cc05ae2aa50e70339a8136038609464bc14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 26 Jul 2026 00:29:48 +0200 Subject: [PATCH 3/7] perf(runtime): raw slot access for the spill buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit js_array_get/set classify the receiver against the typed-array/buffer/SAB registries on every call (three TLS probes — the measured hot leaves of round-robin overflow writes). The spill buffer is a plain GC_TYPE_ARRAY this module allocated itself; store = raw slot write + layout note + generational barrier, the exact triple the retired side-table Vec used. Claude-Session: https://claude.ai/code/session_01QJ5mwMDPc63tNLAFPdthAG --- crates/perry-runtime/src/object/mod.rs | 45 ++++++++++++++------------ 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 4567e25d52..809447f408 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -420,6 +420,26 @@ fn object_spill_enabled() -> bool { }) } +/// Raw in-range element access for the spill buffer. The buffer is a plain +/// `GC_TYPE_ARRAY` this module allocated itself, so the user-facing +/// `js_array_get`/`js_array_set` — which classify the receiver against the +/// typed-array/buffer/SAB registries on EVERY call (three TLS probes, +/// measured as the hot leaves of round-robin overflow writes) — are the +/// wrong tool. Store = raw slot write + layout note + generational barrier, +/// the exact triple the retired side-table Vec store performed. +#[inline] +unsafe fn spill_elements(spill: *const crate::array::ArrayHeader) -> *mut u64 { + (spill as *mut u8).add(std::mem::size_of::()) as *mut u64 +} + +#[inline] +unsafe fn spill_store_slot(spill: *mut crate::array::ArrayHeader, index: usize, vbits: u64) { + let slot = spill_elements(spill).add(index); + *slot = vbits; + crate::gc::layout_note_slot(spill as usize, index, vbits); + crate::gc::runtime_write_barrier_slot(spill as usize, slot as usize, vbits); +} + fn spill_get(obj_ptr: usize, field_index: usize) -> Option { unsafe { let obj = obj_ptr as *mut ObjectHeader; @@ -430,7 +450,7 @@ fn spill_get(obj_ptr: usize, field_index: usize) -> Option { if spill.is_null() || field_index >= (*spill).length as usize { return None; } - let bits = crate::array::js_array_get(spill, field_index as u32).bits(); + let bits = *spill_elements(spill).add(field_index); // Never-written positions are TAG_HOLE from allocation (or // TAG_UNDEFINED via the legacy-parity fillers); both report as // absent, matching the side-table Vec's TAG_UNDEFINED semantics. @@ -452,11 +472,7 @@ fn spill_set(obj_ptr: usize, field_index: usize, vbits: u64) { if !meta.is_null() { let spill = (*meta).spill as *mut crate::array::ArrayHeader; if !spill.is_null() && ((*spill).capacity as usize) > field_index { - crate::array::js_array_set( - spill, - field_index as u32, - crate::value::JSValue::from_bits(vbits), - ); + spill_store_slot(spill, field_index, vbits); return; } } @@ -498,13 +514,8 @@ fn spill_set_slow(obj_ptr: usize, field_index: usize, vbits: u64) { for i in 0..old_len { let bits = *elements.add(i); if bits != crate::value::TAG_HOLE && bits != crate::value::TAG_UNDEFINED { - // Barriered + layout-aware store; in range by - // construction (old_len <= old cap < new_cap). - crate::array::js_array_set( - new_spill, - i as u32, - crate::value::JSValue::from_bits(bits), - ); + // In range by construction (old_len <= old cap < new_cap). + spill_store_slot(new_spill, i, bits); } } } @@ -520,13 +531,7 @@ fn spill_set_slow(obj_ptr: usize, field_index: usize, vbits: u64) { let obj = obj_handle.get_raw_mut_ptr::(); let meta = (*obj).meta; let spill = (*meta).spill as *mut crate::array::ArrayHeader; - // No owner-side layout note: the value lives in the buffer, whose - // own layout/barrier bookkeeping `js_array_set` maintains. - crate::array::js_array_set( - spill, - field_index as u32, - crate::value::JSValue::from_bits(vbits), - ); + spill_store_slot(spill, field_index, vbits); } } From 5470f0f0123bf56fc6eb5cd18b1be878434a7083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 26 Jul 2026 10:11:01 +0200 Subject: [PATCH 4/7] fix(gc): mark path must see meta-reachable children; spill length high-water; test adaptations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes surfaced by the runtime suite on the spill build: - spill_store_slot extends the buffer's length high-water mark: alloc sets length = requested capacity while physical capacity rounds up, so an in-capacity store past length was invisible to spill_get, the GC element range, and the growth copy (structuredClone and every wide-object read lost properties past the first power-of-two boundary). - The MARK path never enumerated the object -> meta edge (only the REWRITE descriptors did): latent for custom prototypes, which are normally rooted elsewhere, fatal for the spill buffer, which is reachable through meta alone. gc_child_slots now yields the meta header slot as a second prefix (index-preserving — payload slots stay aligned with the layout masks) and GC_TYPE_OBJECT_META gained GcLayoutSlotKind::ObjectMeta enumerating its prototype + spill edges; the metadata validator requires it. - Tests: test_overflow_field_bits is mode-aware; the layout-trace test pins GC triggers during its build (overflow writes now allocate GC memory), asserts the pointer mask on the spill buffer, and drains the trace worklist since the overflow child is now three hops deep (obj -> meta -> buffer -> child), exactly like production marking. Claude-Session: https://claude.ai/code/session_01QJ5mwMDPc63tNLAFPdthAG --- changelog.d/6812-object-spill.md | 1 + crates/perry-runtime/src/gc/layout.rs | 40 ++++++++++++++++++- .../src/gc/tests/layout_trace.rs | 25 +++++++++++- crates/perry-runtime/src/gc/types.rs | 21 ++++++++-- crates/perry-runtime/src/object/mod.rs | 32 +++++++++++++++ 5 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 changelog.d/6812-object-spill.md diff --git a/changelog.d/6812-object-spill.md b/changelog.d/6812-object-spill.md new file mode 100644 index 0000000000..96782d5dd0 --- /dev/null +++ b/changelog.d/6812-object-spill.md @@ -0,0 +1 @@ +perf(runtime): #6812 — object-owned overflow storage. Properties past an object's inline slot capacity move from the thread-local side table (TLS fetch + RefCell + pointer-keyed hash probe per access; ~250B map+Vec per wide object, visited/re-keyed/finalized by every GC cycle) into a GC-traced buffer hung off the object's ObjectMeta record: reads are two dependent loads, stores are a raw slot write + layout note + generational barrier, and marking/evacuation/death ride the ordinary object graph (object → meta → buffer). Semantics (absolute field indexing, delete-compaction, enumeration) are unchanged through the same overflow_get/overflow_set entry points. Legacy side table kept one release behind PERRY_OBJECT_SPILL=0 for bisection. diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index e0100a2564..8fa1ee184b 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -362,7 +362,9 @@ pub(super) unsafe fn layout_header_for_user(user_ptr: usize) -> Option<*mut GcHe GcLayoutSlotKind::ArrayElements | GcLayoutSlotKind::ObjectFields | GcLayoutSlotKind::ClosureCaptures => Some(header), - GcLayoutSlotKind::None => None, + // #6812: meta records keep no layout mask — their two child slots + // (prototype, spill) are enumerated unconditionally. + GcLayoutSlotKind::None | GcLayoutSlotKind::ObjectMeta => None, } } @@ -1047,6 +1049,9 @@ pub(super) enum HeapPayloadSlotSelection { pub(crate) struct HeapChildSlotIterator { pub(super) prefix_slot: Option<*mut u64>, + /// #6812: second prefix — the object's `meta` header edge. Kept + /// separate from `prefix_slot` so payload indices stay mask-aligned. + pub(super) meta_slot: Option<*mut u64>, pub(super) payload: HeapSlotRange, pub(super) selection: HeapPayloadSlotSelection, } @@ -1055,6 +1060,7 @@ impl HeapChildSlotIterator { pub(super) fn empty() -> Self { Self { prefix_slot: None, + meta_slot: None, payload: HeapSlotRange::new(std::ptr::null_mut(), 0), selection: HeapPayloadSlotSelection::Empty, } @@ -1068,11 +1074,21 @@ impl HeapChildSlotIterator { let selection = unsafe { heap_payload_slot_selection(header, payload) }; Self { prefix_slot, + meta_slot: None, payload, selection, } } + pub(super) fn with_meta_slot(mut self, slot: Option<*mut u64>) -> Self { + self.meta_slot = slot; + self + } + + pub(super) fn take_meta_child_slot(&mut self) -> Option<*mut u64> { + self.meta_slot.take() + } + pub(super) fn take_prefix_child_slot(&mut self) -> Option<*mut u64> { self.prefix_slot.take() } @@ -1101,6 +1117,9 @@ impl Iterator for HeapChildSlotIterator { if let Some(slot) = self.prefix_slot.take() { return Some(HeapChildSlot::Child(slot, HeapChildSlotReadKind::Prefix)); } + if let Some(slot) = self.meta_slot.take() { + return Some(HeapChildSlot::Child(slot, HeapChildSlotReadKind::Prefix)); + } match &mut self.selection { HeapPayloadSlotSelection::Empty => None, HeapPayloadSlotSelection::PointerFree { @@ -1246,7 +1265,23 @@ pub(super) unsafe fn gc_child_slots(header: *mut GcHeader) -> HeapChildSlotItera return HeapChildSlotIterator::empty(); }; let keys_slot = crate::object::gc_keys_array_slot(obj); + // #6812: the meta record is a raw-pointer child edge; before the + // spill buffer it was enumerated only on the rewrite path, which + // left it invisible to MARKING (latent for custom prototypes, + // which are usually rooted elsewhere; fatal for the spill + // buffer, reachable through meta alone). A second prefix slot + // keeps payload slot indices aligned with the layout masks. HeapChildSlotIterator::new(header, keys_slot, range) + .with_meta_slot(crate::object::gc_object_meta_slot(user_ptr as usize)) + } + GcLayoutSlotKind::ObjectMeta => { + // #6812: prototype (NaN-boxed / raw / sentinel) as the prefix + // slot, the raw spill-buffer pointer as a 1-slot range. Mirrors + // the rewrite descriptor arm — marking must see the same edges. + let meta = user_ptr as *mut crate::object::ObjectMeta; + let proto_slot = Some(&mut (*meta).prototype as *mut u64); + let range = HeapSlotRange::new(&mut (*meta).spill as *mut u64, 1); + HeapChildSlotIterator::new(header, proto_slot, range) } GcLayoutSlotKind::ClosureCaptures => { let closure = user_ptr as *mut crate::closure::ClosureHeader; @@ -1324,6 +1359,9 @@ pub(super) unsafe fn visit_gc_layout_slot_descriptors( if let Some(slot) = child_slots.take_prefix_child_slot() { visit(fixed_slot(slot).with_layout(HeapChildSlotReadKind::Prefix)); } + if let Some(slot) = child_slots.take_meta_child_slot() { + visit(fixed_slot(slot).with_layout(HeapChildSlotReadKind::Prefix)); + } match child_slots.payload_scan() { HeapPayloadSlotScan::Empty => {} diff --git a/crates/perry-runtime/src/gc/tests/layout_trace.rs b/crates/perry-runtime/src/gc/tests/layout_trace.rs index c90e5350be..7dd4ac875f 100644 --- a/crates/perry-runtime/src/gc/tests/layout_trace.rs +++ b/crates/perry-runtime/src/gc/tests/layout_trace.rs @@ -1043,6 +1043,11 @@ fn test_heap_child_iterator_pointer_free_object_yields_no_child_slots() { fn test_layout_mask_overflow_fields_and_array_grow_transfer() { clear_marks(); clear_mark_seeds(); + // #6812 spill: overflow writes now allocate GC memory (meta record + + // spill buffer), so an automatic minor GC mid-build could move `obj` + // out from under this test's raw pointers. The test asserts layout and + // tracing, not move-resilience — pin the heap while building. + let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); let child = crate::string::js_string_from_bytes(b"overflow-child".as_ptr(), 14) as *mut u8; let child_header = unsafe { header_from_user_ptr(child) }; @@ -1058,11 +1063,29 @@ fn test_layout_mask_overflow_fields_and_array_grow_transfer() { crate::object::js_object_set_field_by_name(obj, key, value); } - assert_eq!(test_layout_pointer_slot_count(obj as usize, 9), Some(1)); + // #6812 spill: the k8 pointer lives in the object-owned spill buffer + // (owner inline slots hold only k0..k3 numerics), so the pointer-slot + // count moves from the owner's mask to the buffer's. Legacy mode keeps + // the original owner-mask expectation. + if crate::object::test_object_spill_enabled() { + let spill = crate::object::test_spill_buffer_addr(obj as usize); + assert_ne!(spill, 0, "overflow write must have created a spill buffer"); + assert_eq!(test_layout_pointer_slot_count(spill, 9), Some(1)); + } else { + assert_eq!(test_layout_pointer_slot_count(obj as usize, 9), Some(1)); + } let valid_ptrs = build_valid_pointer_set(); let mut worklist = Vec::new(); unsafe { trace_object(obj as *mut u8, &valid_ptrs, &mut worklist); + // #6812 spill: the overflow value is no longer owner-adjacent — the + // chain is obj → meta record → spill buffer → child, so drain the + // worklist exactly like production marking does instead of relying + // on a single hop. + while let Some(queued) = worklist.pop() { + let user = (queued as *mut u8).add(crate::gc::GC_HEADER_SIZE); + trace_object(user, &valid_ptrs, &mut worklist); + } } unsafe { assert_ne!((*child_header).gc_flags & GC_FLAG_MARKED, 0); diff --git a/crates/perry-runtime/src/gc/types.rs b/crates/perry-runtime/src/gc/types.rs index f48fd2d837..bf862f7cc8 100644 --- a/crates/perry-runtime/src/gc/types.rs +++ b/crates/perry-runtime/src/gc/types.rs @@ -107,6 +107,13 @@ pub(crate) enum GcLayoutSlotKind { ArrayElements, ObjectFields, ClosureCaptures, + /// #6812: ObjectMeta records carry two live edges — the custom + /// `[[Prototype]]` value and the raw spill-buffer pointer. Before the + /// spill buffer these were enumerated only on the REWRITE path, which + /// left them invisible to marking (latent for prototypes, which are + /// normally rooted elsewhere; fatal for the spill buffer, which is + /// reachable through meta alone). + ObjectMeta, } #[allow(dead_code)] @@ -548,7 +555,7 @@ pub(super) static GC_TYPE_INFO_BY_ID: [Option; MALLOC_KIND_BUCKET_CO GcAllocationPolicy::Arena, true, GcRewriteDescriptorKind::ObjectMeta, - GcLayoutSlotKind::None, + GcLayoutSlotKind::ObjectMeta, // Movable: the owner's `meta` header slot is a raw-pointer child // edge (visited in the Object rewrite descriptor), so evacuation // rewrites it like any other reference — no address-keyed side @@ -789,14 +796,22 @@ pub(crate) fn validate_gc_type_info(info: &GcTypeInfo) -> Result<(), &'static st | GcRewriteDescriptorKind::Error | GcRewriteDescriptorKind::Map | GcRewriteDescriptorKind::LazyArray - | GcRewriteDescriptorKind::Set - | GcRewriteDescriptorKind::ObjectMeta => { + | GcRewriteDescriptorKind::Set => { if info.layout_slot_kind != GcLayoutSlotKind::None { return Err( "external-backed rewrite descriptor must not expose payload layout slots", ); } } + GcRewriteDescriptorKind::ObjectMeta => { + // #6812: meta records expose their two child edges (prototype, + // spill buffer) to MARKING via GcLayoutSlotKind::ObjectMeta — + // the spill buffer is reachable through meta alone, so a + // rewrite-only descriptor would leave it invisible to liveness. + if info.layout_slot_kind != GcLayoutSlotKind::ObjectMeta { + return Err("object-meta descriptor must expose its child edges to marking"); + } + } GcRewriteDescriptorKind::NativeTypedView | GcRewriteDescriptorKind::NativePodView => { if info.layout_slot_kind != GcLayoutSlotKind::None { return Err("native view rewrite descriptor must use fixed slots only"); diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 809447f408..7173bf79b4 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -436,6 +436,16 @@ unsafe fn spill_elements(spill: *const crate::array::ArrayHeader) -> *mut u64 { unsafe fn spill_store_slot(spill: *mut crate::array::ArrayHeader, index: usize, vbits: u64) { let slot = spill_elements(spill).add(index); *slot = vbits; + // Length is the buffer's high-water mark: `js_array_alloc_with_length` + // sets length = REQUESTED capacity while the physical capacity rounds up + // (MIN_ARRAY_CAPACITY), and the in-capacity fast path stores past the + // current length. Everything keys off length — `spill_get`'s bounds + // check, the GC element range (a value past length is invisible to + // marking/rewriting), and the growth copy — so extend it here. Slots + // between the old and new length are TAG_HOLE from allocation. + if index >= (*spill).length as usize { + (*spill).length = (index + 1) as u32; + } crate::gc::layout_note_slot(spill as usize, index, vbits); crate::gc::runtime_write_barrier_slot(spill as usize, slot as usize, vbits); } @@ -1536,6 +1546,11 @@ pub(crate) fn test_overflow_fields_root() -> (usize, u64) { #[cfg(test)] pub(crate) fn test_overflow_field_bits(owner: usize, index: usize) -> u64 { + // Mode-aware probe: overflow values live in the spill buffer by default + // and in the legacy side table under PERRY_OBJECT_SPILL=0. + if object_spill_enabled() { + return spill_get(owner, index).unwrap_or(0); + } crate::state::state() .object_hot .overflow_fields @@ -1545,6 +1560,23 @@ pub(crate) fn test_overflow_field_bits(owner: usize, index: usize) -> u64 { .unwrap_or(0) } +#[cfg(test)] +pub(crate) fn test_object_spill_enabled() -> bool { + object_spill_enabled() +} + +/// Test probe: address of the owner's spill buffer allocation (0 = none). +#[cfg(test)] +pub(crate) fn test_spill_buffer_addr(owner: usize) -> usize { + unsafe { + let obj = owner as *const ObjectHeader; + if (*obj).meta.is_null() { + return 0; + } + (*(*obj).meta).spill as usize + } +} + #[cfg(test)] pub(crate) fn test_seed_keys_index_entry(owner: usize) { shapes::test_seed_shape_entry(owner); From 6ec6982cd8bf73499386932ec90a65d344f8ed56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 26 Jul 2026 11:51:24 +0200 Subject: [PATCH 5/7] =?UTF-8?q?fix(runtime):=20spill=20only=20for=20genuin?= =?UTF-8?q?e=20shaped=20objects=20=E2=80=94=20exotic=20owners=20keep=20the?= =?UTF-8?q?=20side=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit overflow_get/overflow_set callers can pass non-ObjectHeader owners (the zlib/fs gap test crashed in assert.doesNotThrow's error matching: EXC_BAD_ACCESS deref'ing garbage 'meta' bytes off a non-object header). The legacy side table was address-keyed and safe for any owner; spill mode now classifies owners with the canonical header probe (GC_TYPE_OBJECT + regex-magic exclusion, mirroring gc_object_meta_slot) and routes exotic owners to the legacy table in both modes — their semantics are unchanged. Claude-Session: https://claude.ai/code/session_01QJ5mwMDPc63tNLAFPdthAG --- crates/perry-runtime/src/object/mod.rs | 28 +++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 7173bf79b4..be179d1e06 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -450,6 +450,28 @@ unsafe fn spill_store_slot(spill: *mut crate::array::ArrayHeader, index: usize, crate::gc::runtime_write_barrier_slot(spill as usize, slot as usize, vbits); } +/// Only genuine shaped objects carry a meta record at the ObjectHeader +/// offset. Exotic GC_TYPE_OBJECT aliases (RegExpHeader) and every other +/// GC type (errors, maps, ...) have unrelated bytes there — the legacy +/// side table was address-keyed and safe for ANY owner, so those owners +/// keep it (in both modes) instead of deref'ing garbage. Classification +/// via the canonical header probe, mirroring `gc_object_meta_slot`. +#[inline] +unsafe fn spill_capable_owner(obj_ptr: usize) -> bool { + if obj_ptr == 0 { + return false; + } + match crate::value::addr_class::try_read_gc_header(obj_ptr) { + Some(h) => { + h.obj_type == crate::gc::GC_TYPE_OBJECT + && !crate::regex::regex_header_has_magic( + obj_ptr as *const crate::regex::RegExpHeader, + ) + } + None => false, + } +} + fn spill_get(obj_ptr: usize, field_index: usize) -> Option { unsafe { let obj = obj_ptr as *mut ObjectHeader; @@ -551,7 +573,7 @@ fn spill_set_slow(obj_ptr: usize, field_index: usize, vbits: u64) { /// "no Vec entry at all" case. #[inline] fn overflow_get(obj_ptr: usize, field_index: usize) -> Option { - if object_spill_enabled() { + if object_spill_enabled() && unsafe { spill_capable_owner(obj_ptr) } { return spill_get(obj_ptr, field_index); } crate::state::state() @@ -635,7 +657,7 @@ pub(crate) fn learned_inline_field_count(class_id: u32) -> u32 { /// overflow slots fill in sequence. #[inline] fn overflow_set(obj_ptr: usize, field_index: usize, vbits: u64) { - if object_spill_enabled() { + if object_spill_enabled() && unsafe { spill_capable_owner(obj_ptr) } { return spill_set(obj_ptr, field_index, vbits); } // Learn the class's true width so FUTURE instances allocate it inline. @@ -1548,7 +1570,7 @@ pub(crate) fn test_overflow_fields_root() -> (usize, u64) { pub(crate) fn test_overflow_field_bits(owner: usize, index: usize) -> u64 { // Mode-aware probe: overflow values live in the spill buffer by default // and in the legacy side table under PERRY_OBJECT_SPILL=0. - if object_spill_enabled() { + if object_spill_enabled() && unsafe { spill_capable_owner(owner) } { return spill_get(owner, index).unwrap_or(0); } crate::state::state() From a5765fca49abe687d143e3c944785da631eed6d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 26 Jul 2026 15:41:01 +0200 Subject: [PATCH 6/7] test: triage six cold-cache auto-opt compile failures against #6847 Pre-existing on main (reproduced at 21a3d00e1), masked by warm /tmp compile-object caches; not parity regressions. Claude-Session: https://claude.ai/code/session_01QJ5mwMDPc63tNLAFPdthAG --- test-parity/known_failures.json | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test-parity/known_failures.json b/test-parity/known_failures.json index b1ddcbef0e..21acb306e3 100644 --- a/test-parity/known_failures.json +++ b/test-parity/known_failures.json @@ -211,5 +211,35 @@ "added": "2026-07-13", "category": "bug-open", "reason": "DisposableStack/Symbol.dispose surface incomplete: `.disposed` returns undefined where Node returns false/true, and the dispose path leaves the adopt/defer callback count at 0. NEWLY VISIBLE, not a regression: DisposableStack is Node 24+, so under CI's old Node 22 pin *node itself* exited non-zero, the harness classified the test `node_fail`, and it was dropped from the gate entirely. Raising the oracle to 26 (.node-version) makes the pre-existing gap observable for the first time. Perry's implementation lives in crates/perry-runtime/src/disposable.rs. Flips to PASS when #6364 lands." + }, + "test_gap_zlib_4917_level": { + "issue": "6847", + "added": "2026-07-26", + "category": "toolchain", + "reason": "auto-opt pairs the ext-zlib provider archive with a feature-stripped stdlib rebuild: undefined js_zlib_deflate_raw_sync + panic_unwind symbols on a COLD object cache (warm /tmp caches mask it, which is how it went unnoticed). Compiles fine with PERRY_NO_AUTO_OPTIMIZE=1. Not a parity regression." + }, + "test_gap_zlib_fs_assert_2935_2752_2971": { + "issue": "6847", + "added": "2026-07-26", + "category": "toolchain", + "reason": "auto-opt pairs the ext-zlib provider archive with a feature-stripped stdlib rebuild: undefined js_zlib_deflate_raw_sync + panic_unwind symbols on a COLD object cache (warm /tmp caches mask it, which is how it went unnoticed). Compiles fine with PERRY_NO_AUTO_OPTIMIZE=1. Not a parity regression." + }, + "test_gap_3662_node_argvalidation": { + "issue": "6847", + "added": "2026-07-26", + "category": "toolchain", + "reason": "auto-opt pairs the ext-zlib provider archive with a feature-stripped stdlib rebuild: undefined js_zlib_deflate_raw_sync + panic_unwind symbols on a COLD object cache (warm /tmp caches mask it, which is how it went unnoticed). Compiles fine with PERRY_NO_AUTO_OPTIMIZE=1. Not a parity regression." + }, + "test_gap_constants_tail_3683plus": { + "issue": "6847", + "added": "2026-07-26", + "category": "toolchain", + "reason": "auto-opt pairs the ext-zlib provider archive with a feature-stripped stdlib rebuild: undefined js_zlib_deflate_raw_sync + panic_unwind symbols on a COLD object cache (warm /tmp caches mask it, which is how it went unnoticed). Compiles fine with PERRY_NO_AUTO_OPTIMIZE=1. Not a parity regression." + }, + "test_gap_handle_band_object_ops": { + "issue": "6847", + "added": "2026-07-26", + "category": "toolchain", + "reason": "auto-opt pairs the ext-zlib provider archive with a feature-stripped stdlib rebuild: undefined js_zlib_deflate_raw_sync + panic_unwind symbols on a COLD object cache (warm /tmp caches mask it, which is how it went unnoticed). Compiles fine with PERRY_NO_AUTO_OPTIMIZE=1. Not a parity regression." } } From 6231dff67b190d03e77233ed16bd6c707cbe71c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 26 Jul 2026 16:52:09 +0200 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20CodeRabbit=20round=201=20on=20#6849?= =?UTF-8?q?=20=E2=80=94=20single=20meta-edge=20rewrite=20visit;=2016M=20sp?= =?UTF-8?q?ill=20index=20ceiling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Object rewrite arm's explicit gc_object_meta_slot visit became a duplicate once the child-slot iterator gained the meta second-prefix; drop it so verification statistics count the edge once. - Spill indices share the runtime's canonical 16M field ceiling (the layout_note_slot / write-guard cap); larger indices stay on the address-keyed legacy table like exotic owners, making the u32 capacity conversion exact by construction. Arena allocation panics on genuine OOM (after one emergency reclaim) and never returns null. Claude-Session: https://claude.ai/code/session_01QJ5mwMDPc63tNLAFPdthAG --- crates/perry-runtime/src/gc/layout.rs | 16 +++++++-------- crates/perry-runtime/src/object/mod.rs | 28 ++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index 8fa1ee184b..bf984cccae 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -1422,16 +1422,14 @@ pub(super) unsafe fn visit_gc_rewrite_slot_descriptors( visit_gc_layout_slot_descriptors(header, &mut visit); } GcRewriteDescriptorKind::Object => { + // #6759 Phase B / #6812: the per-object meta record is a raw- + // pointer child edge exactly like `keys_array`'s prefix slot. + // Since the child-slot iterator gained the meta second-prefix + // (so MARKING sees it too), the layout-descriptor visit below + // already emits it — no explicit `gc_object_meta_slot` visit + // here, or the rewrite pass would hand the same slot to the + // visitor twice and double-count in verification statistics. visit_gc_layout_slot_descriptors(header, &mut visit); - // #6759 Phase B: the per-object meta record is a GC allocation - // reachable ONLY through this header slot — a raw-pointer child - // edge exactly like `keys_array`'s prefix slot (marked live here, - // rewritten when the record itself is evacuated). The accessor - // returns `None` for RegExp headers, whose bytes at the meta - // offset are native data. - if let Some(slot) = crate::object::gc_object_meta_slot(user_ptr as usize) { - visit(fixed_slot(slot)); - } crate::object::visit_overflow_field_slots_mut(user_ptr as usize, |slot| { visit(fixed_slot(slot)); }); diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index be179d1e06..042969b9fd 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -528,7 +528,12 @@ fn spill_set_slow(obj_ptr: usize, field_index: usize, vbits: u64) { let spill = (*meta).spill as *mut crate::array::ArrayHeader; let needed = field_index + 1; if spill.is_null() || ((*spill).capacity as usize) < needed { - let new_cap = u32::try_from(needed.next_power_of_two().max(8)).unwrap_or(u32::MAX); + // In range by the SPILL_MAX_FIELD_INDEX dispatch gate, so the + // conversion is exact (2^24 max); arena allocation panics on + // genuine OOM (after one emergency reclaim) and never returns + // null, so the store below always has a live buffer. + let new_cap = + u32::try_from(needed.next_power_of_two().max(8)).expect("bounded by dispatch gate"); // length == capacity and every slot TAG_HOLE from birth, so the // GC element range covers the whole buffer and in-range // `js_array_set` can never trigger array growth/forwarding — @@ -571,9 +576,18 @@ fn spill_set_slow(obj_ptr: usize, field_index: usize, vbits: u64) { /// Positions never written are stored as `TAG_UNDEFINED`; this helper reports /// them as `None` so callers can return JS `undefined` uniformly with the /// "no Vec entry at all" case. +/// Spill indices share the runtime's canonical 16M field ceiling (the +/// same cap `layout_note_slot` and the write-loop guard enforce); a larger +/// index would need a >128MB buffer for one property, so it stays on the +/// address-keyed legacy table like exotic owners do. +const SPILL_MAX_FIELD_INDEX: usize = 16_000_000; + #[inline] fn overflow_get(obj_ptr: usize, field_index: usize) -> Option { - if object_spill_enabled() && unsafe { spill_capable_owner(obj_ptr) } { + if object_spill_enabled() + && field_index < SPILL_MAX_FIELD_INDEX + && unsafe { spill_capable_owner(obj_ptr) } + { return spill_get(obj_ptr, field_index); } crate::state::state() @@ -657,7 +671,10 @@ pub(crate) fn learned_inline_field_count(class_id: u32) -> u32 { /// overflow slots fill in sequence. #[inline] fn overflow_set(obj_ptr: usize, field_index: usize, vbits: u64) { - if object_spill_enabled() && unsafe { spill_capable_owner(obj_ptr) } { + if object_spill_enabled() + && field_index < SPILL_MAX_FIELD_INDEX + && unsafe { spill_capable_owner(obj_ptr) } + { return spill_set(obj_ptr, field_index, vbits); } // Learn the class's true width so FUTURE instances allocate it inline. @@ -1570,7 +1587,10 @@ pub(crate) fn test_overflow_fields_root() -> (usize, u64) { pub(crate) fn test_overflow_field_bits(owner: usize, index: usize) -> u64 { // Mode-aware probe: overflow values live in the spill buffer by default // and in the legacy side table under PERRY_OBJECT_SPILL=0. - if object_spill_enabled() && unsafe { spill_capable_owner(owner) } { + if object_spill_enabled() + && index < SPILL_MAX_FIELD_INDEX + && unsafe { spill_capable_owner(owner) } + { return spill_get(owner, index).unwrap_or(0); } crate::state::state()