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 32e7748fb4..bf984cccae 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 => {} @@ -1384,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)); }); @@ -1515,6 +1551,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/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 e15034cd1b..042969b9fd 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -395,12 +395,201 @@ 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") + ) + }) +} + +/// 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; + // 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); +} + +/// 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; + 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 = *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. + (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)); + // 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 { + spill_store_slot(spill, field_index, 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(); + 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 { + // 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 — + // `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 { + // In range by construction (old_len <= old cap < new_cap). + spill_store_slot(new_spill, i, 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; + spill_store_slot(spill, field_index, 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. +/// 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() + && field_index < SPILL_MAX_FIELD_INDEX + && unsafe { spill_capable_owner(obj_ptr) } + { + return spill_get(obj_ptr, field_index); + } crate::state::state() .object_hot .overflow_fields @@ -482,6 +671,12 @@ 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() + && 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. unsafe { let hdr = obj_ptr as *const ObjectHeader; @@ -1390,6 +1585,14 @@ 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() + && index < SPILL_MAX_FIELD_INDEX + && unsafe { spill_capable_owner(owner) } + { + return spill_get(owner, index).unwrap_or(0); + } crate::state::state() .object_hot .overflow_fields @@ -1399,6 +1602,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); @@ -1625,6 +1845,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 +1888,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; 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." } }