From e0ae8ff6b7ae1fbc398785426b9fb79fb7cf260a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 30 Jul 2026 09:24:26 +0200 Subject: [PATCH 1/3] fix(gc): run the allocation-point GC trigger outside the &mut Arena borrow (#7022) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Arena::alloc(&mut self, ..)` called `gc_check_trigger()` from inside its own borrow. A collection allocates into the arenas — promotion and C4b evacuation call `arena_alloc_gc_old`, an evacuating minor (#7019) fills a survivor semispace — and either can reach `Arena::install_fresh_block` → `self.blocks.push(..)` on the SAME arena the allocating frame is holding. The Vec growth frees the buffer the outer frame goes on to index, and `&mut` carries `noalias`, so the outer frame may also have cached `blocks.ptr`/`len` across the call. Measured on the #7022 reproducer: `install_fresh_block gen=Old space=Old` fires while `Arena::alloc` on the old arena holds the borrow, 204 times in one run, and `self.blocks`'s length changes underneath it. The crashing stack is exactly that shape — `js_array_grow` → `arena_alloc_gc_old` → `Arena::alloc` → `gc_check_trigger` → full mark-sweep → `ValidPointerSetBuilder::step` → `RawVec::grow_one` → `_mi_theap_realloc_zero`, faulting on a corrupt mimalloc heap. `arena_cell_alloc(*mut Arena, ..)` is the new collecting entry point: current block under a borrow that ends with the statement, `gc_check_trigger()` with no arena borrow live, then a fresh borrow for the slow path. `Arena::alloc` is now collection-free, and the five thread-local entry points route through the new one. The two that also touch `INLINE_STATE` keep those borrows short for the same reason: `resync_inline_to_current` mutates `INLINE_STATE` from inside the collection. Same commit, same compiler, only the four arena files differing; `test_gap_repsel_gc_stress` under `PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off`, auto-optimized release binaries: 139 x 20 before / 0 x 20 after at the default 16 MB nursery cap, clean across a 13-cap sweep, and 5/5 -> 0/5 on the force-evacuate, force+verify and from-space-scan arms. Claude-Session: https://claude.ai/code/session_01G4k1vE6PVb53m2dRZ2aDtv --- crates/perry-runtime/src/arena/allocators.rs | 57 ++++--- crates/perry-runtime/src/arena/block.rs | 163 +++++++++++++++++-- crates/perry-runtime/src/arena/inline.rs | 28 +++- crates/perry-runtime/src/arena/mod.rs | 10 +- crates/perry-runtime/src/arena/tests.rs | 52 ++++++ 5 files changed, 262 insertions(+), 48 deletions(-) diff --git a/crates/perry-runtime/src/arena/allocators.rs b/crates/perry-runtime/src/arena/allocators.rs index ab725682ae..ae287006ca 100644 --- a/crates/perry-runtime/src/arena/allocators.rs +++ b/crates/perry-runtime/src/arena/allocators.rs @@ -14,21 +14,32 @@ use super::*; #[inline] pub fn arena_alloc(size: usize, align: usize) -> *mut u8 { INLINE_STATE.with(|inline_s| unsafe { - let inline = &mut *inline_s.get(); + let inline_ptr = inline_s.get(); ARENA.with(|a| { - let arena = &mut *(*a).get(); + let arena_ptr = (*a).get(); // Sync inline → block before allocating, if the inline - // state has been initialized. - if !inline.data.is_null() { - arena.blocks[arena.current].offset = inline.offset; + // state has been initialized. Borrows are deliberately + // short-lived: `arena_cell_alloc` runs the GC between two + // disjoint borrows, and the collector mutates BOTH the arena + // and `INLINE_STATE` (`Arena::resync_inline_to_current`). #7022. + if !(*inline_ptr).data.is_null() { + let offset = (*inline_ptr).offset; + let arena = &mut *arena_ptr; + let current = arena.current; + arena.blocks[current].offset = offset; } - let ptr = arena.alloc(size, align); + let ptr = crate::arena::arena_cell_alloc(arena_ptr, size, align); // Resync block → inline (may have advanced to a new block). - if !inline.data.is_null() { - let block = &arena.blocks[arena.current]; - inline.data = block.data; - inline.offset = block.offset; - inline.size = block.size; + if !(*inline_ptr).data.is_null() { + let (data, offset, block_size) = { + let arena = &*arena_ptr; + let block = &arena.blocks[arena.current]; + (block.data, block.offset, block.size) + }; + let inline = &mut *inline_ptr; + inline.data = data; + inline.offset = offset; + inline.size = block_size; } ptr }) @@ -41,10 +52,7 @@ pub fn arena_alloc(size: usize, align: usize) -> *mut u8 { /// (`js_string_from_bytes_longlived`, `js_array_alloc_with_length_longlived`), /// not hot-path `new ClassName()` bump allocations. pub fn arena_alloc_longlived(size: usize, align: usize) -> *mut u8 { - LONGLIVED_ARENA.with(|a| unsafe { - let arena = &mut *a.get(); - arena.alloc(size, align) - }) + LONGLIVED_ARENA.with(|a| unsafe { crate::arena::arena_cell_alloc(a.get(), size, align) }) } /// Allocate a GcHeader-prefixed object from the longlived arena (issue #179). @@ -90,10 +98,7 @@ pub fn arena_alloc_gc_longlived(size: usize, align: usize, obj_type: u8) -> *mut /// touch) so codegen's hot bump-pointer loop on `new ClassName()` /// stays exclusively pinned to the nursery. pub fn arena_alloc_old(size: usize, align: usize) -> *mut u8 { - OLD_ARENA.with(|a| unsafe { - let arena = &mut *a.get(); - arena.alloc(size, align) - }) + OLD_ARENA.with(|a| unsafe { crate::arena::arena_cell_alloc(a.get(), size, align) }) } pub(crate) fn arena_alloc_old_excluding_pages( @@ -174,6 +179,16 @@ pub(crate) fn with_survivor_arena_mut(idx: usize, f: impl FnOnce(&mut Arena) } } +/// Raw-pointer counterpart of [`with_survivor_arena_mut`] for callers that must +/// not hold an `&mut Arena` across a GC trigger (#7022). +pub(crate) fn with_survivor_arena_cell(idx: usize, f: impl FnOnce(*mut Arena) -> R) -> R { + match idx { + 0 => SURVIVOR_ARENA_0.with(|a| f(a.get())), + 1 => SURVIVOR_ARENA_1.with(|a| f(a.get())), + _ => unreachable!("invalid survivor arena index"), + } +} + pub(crate) fn with_survivor_arena(idx: usize, f: impl FnOnce(&Arena) -> R) -> R { match idx { 0 => SURVIVOR_ARENA_0.with(|a| unsafe { f(&*a.get()) }), @@ -189,7 +204,9 @@ pub(crate) fn arena_alloc_gc_survivor(size: usize, align: usize, obj_type: u8) - let total = gc_padded_total_size(size, align); let idx = inactive_survivor_index(); - let raw = with_survivor_arena_mut(idx, |arena| arena.alloc(total, align)); + let raw = with_survivor_arena_cell(idx, |cell| unsafe { + crate::arena::arena_cell_alloc(cell, total, align) + }); unsafe { let header = raw as *mut GcHeader; diff --git a/crates/perry-runtime/src/arena/block.rs b/crates/perry-runtime/src/arena/block.rs index 50b5b502e5..8ccf087d78 100644 --- a/crates/perry-runtime/src/arena/block.rs +++ b/crates/perry-runtime/src/arena/block.rs @@ -341,24 +341,18 @@ impl Arena { } } + /// Fast path only: bump within the current block. Never collects, never + /// pushes a block — so it is safe to call under a short `&mut` borrow. #[inline] - pub(crate) fn alloc(&mut self, size: usize, align: usize) -> *mut u8 { - // Try current block first - if let Some(ptr) = self.try_block_alloc(self.current, size, align) { - return ptr; - } - - // Current block is full. Check GC trigger first — if it fires - // and reclaims at least one fully-empty block (via - // `arena_reset_empty_blocks`), we may be able to reuse that - // block instead of pushing a new one. - // - // Threshold pressure is paid through bounded mutator-assist work. - // A completed assist cycle may reset blocks before we retry; an - // incomplete cycle leaves the debt active for later host or allocator - // steps. - crate::gc::gc_check_trigger(); + pub(crate) fn try_alloc_current(&mut self, size: usize, align: usize) -> Option<*mut u8> { + self.try_block_alloc(self.current, size, align) + } + /// Everything `alloc` does *after* the GC trigger: retry the (possibly + /// newly reset) current block, scan the other blocks, then install a fresh + /// one. Split out of `alloc` for #7022 — see [`arena_cell_alloc`]. + #[inline] + pub(crate) fn alloc_after_gc(&mut self, size: usize, align: usize) -> *mut u8 { // Retry the (possibly newly-reset) current block. arena.current // may have been changed by arena_reset_empty_blocks to point // at the lowest reset block. @@ -391,6 +385,17 @@ impl Arena { self.alloc_fresh_block(size, align) } + /// GC-free allocation. Used by paths that already run inside a collection + /// (`alloc_excluding_pages`) and by the tests; the collecting entry point + /// is [`arena_cell_alloc`]. + #[inline] + pub(crate) fn alloc(&mut self, size: usize, align: usize) -> *mut u8 { + if let Some(ptr) = self.try_alloc_current(size, align) { + return ptr; + } + self.alloc_after_gc(size, align) + } + pub(crate) fn alloc_excluding_pages( &mut self, size: usize, @@ -420,7 +425,133 @@ impl Arena { } } +/// Allocate from an arena that lives behind a thread-local `UnsafeCell`, +/// running the allocation-point GC trigger **between** two disjoint borrows. +/// +/// # Why the borrow has to be split (#7022) +/// +/// `gc_check_trigger()` can run a full mark-sweep or an evacuating minor, and +/// both of those *allocate into the arenas*: promotion and C4b evacuation call +/// `arena_alloc_gc_old`, the copying minor fills a survivor semispace, and +/// either may reach `install_fresh_block` → `self.blocks.push(..)`. When the +/// trigger was called from inside `Arena::alloc(&mut self, ..)`, that push ran +/// against the **same** `Arena` the caller was holding a live `&mut` to: a +/// `Vec` growth then frees the buffer the outer frame goes on to index +/// (`self.blocks[idx]`), and `&mut` carries `noalias`, so the outer frame is +/// also entitled to have cached `blocks.ptr`/`len` across the call. Measured on +/// the #7022 reproducer: `install_fresh_block gen=Old space=Old` fires while +/// `Arena::alloc` on the old arena holds the borrow, and `self.blocks`'s length +/// changes underneath it. +/// +/// #7019 (default-on evacuating young-gen scavenge) is what made this latent +/// hazard live: before it, a collection triggered from an allocation point did +/// not itself allocate arena blocks anywhere near this often. +/// +/// Taking a `*mut Arena` and re-deriving a short-lived `&mut` per statement +/// keeps the two borrows disjoint, so the collector may freely mutate the arena +/// while it runs. +/// +/// # Safety +/// `arena` must be the `UnsafeCell` payload of a live thread-local `Arena` for +/// the current thread. +#[inline] +pub(crate) unsafe fn arena_cell_alloc(arena: *mut Arena, size: usize, align: usize) -> *mut u8 { + // Try current block first, under a borrow that ends with this statement. + { + let _borrow = ArenaBorrowGuard::new(); + if let Some(ptr) = (*arena).try_alloc_current(size, align) { + return ptr; + } + } + + // Current block is full. Check the GC trigger first — if it fires and + // reclaims at least one fully-empty block (via `arena_reset_empty_blocks`), + // we may be able to reuse that block instead of pushing a new one. + // + // Threshold pressure is paid through bounded mutator-assist work. A + // completed assist cycle may reset blocks before we retry; an incomplete + // cycle leaves the debt active for later host or allocator steps. + // + // NO ARENA BORROW IS LIVE HERE. See the function docs. + note_gc_trigger_arena_borrow_depth(); + crate::gc::gc_check_trigger(); + + let _borrow = ArenaBorrowGuard::new(); + (*arena).alloc_after_gc(size, align) +} + +// --------------------------------------------------------------------------- +// #7022 invariant instrumentation: "no `&mut Arena` borrow is live while the +// allocation-point GC trigger runs". +// +// Compiled ONLY under `cfg(test)`, so production allocation pays nothing. The +// unit tests in `arena::tests` read `gc_trigger_arena_borrow_depth()` after +// forcing an arena allocation past its current block; a refactor that puts the +// trigger back inside the borrow makes them red. +// --------------------------------------------------------------------------- + +#[cfg(test)] +thread_local! { + /// Number of `&mut Arena` borrows currently live inside `arena_cell_alloc`. + static ARENA_BORROW_DEPTH: Cell = const { Cell::new(0) }; + /// `ARENA_BORROW_DEPTH` sampled immediately before the most recent + /// `gc_check_trigger()` call made from `arena_cell_alloc`, and how many + /// such calls have been made. + static GC_TRIGGER_BORROW_DEPTH: Cell = const { Cell::new(u32::MAX) }; + static GC_TRIGGER_CALLS: Cell = const { Cell::new(0) }; +} + +/// RAII marker for a live `&mut Arena` borrow (test builds only). +pub(crate) struct ArenaBorrowGuard; + +impl ArenaBorrowGuard { + #[inline(always)] + fn new() -> Self { + #[cfg(test)] + ARENA_BORROW_DEPTH.with(|d| d.set(d.get() + 1)); + ArenaBorrowGuard + } +} + +impl Drop for ArenaBorrowGuard { + #[inline(always)] + fn drop(&mut self) { + #[cfg(test)] + ARENA_BORROW_DEPTH.with(|d| d.set(d.get().saturating_sub(1))); + } +} + +#[inline(always)] +fn note_gc_trigger_arena_borrow_depth() { + #[cfg(test)] + { + let depth = ARENA_BORROW_DEPTH.with(Cell::get); + GC_TRIGGER_BORROW_DEPTH.with(|d| d.set(depth)); + GC_TRIGGER_CALLS.with(|c| c.set(c.get() + 1)); + } +} + +/// Arena-borrow depth observed at the most recent allocation-point GC trigger, +/// or `u32::MAX` if no trigger has been reached yet on this thread. +#[cfg(test)] +pub(crate) fn gc_trigger_arena_borrow_depth() -> u32 { + GC_TRIGGER_BORROW_DEPTH.with(Cell::get) +} + +/// How many allocation-point GC triggers `arena_cell_alloc` has reached. +#[cfg(test)] +pub(crate) fn gc_trigger_arena_calls() -> u32 { + GC_TRIGGER_CALLS.with(Cell::get) +} + +#[cfg(test)] +pub(crate) fn reset_gc_trigger_arena_probe() { + GC_TRIGGER_BORROW_DEPTH.with(|d| d.set(u32::MAX)); + GC_TRIGGER_CALLS.with(|c| c.set(0)); +} + thread_local! { + /// Cached running sum of `block.size` across every arena (general, /// longlived, old-gen). `arena_total_bytes()` previously walked /// every block of every arena summing this on every call — and diff --git a/crates/perry-runtime/src/arena/inline.rs b/crates/perry-runtime/src/arena/inline.rs index 0b6d7ccab0..9ce8eec0a2 100644 --- a/crates/perry-runtime/src/arena/inline.rs +++ b/crates/perry-runtime/src/arena/inline.rs @@ -62,20 +62,30 @@ pub extern "C" fn js_inline_arena_slow_alloc( size: usize, align: usize, ) -> *mut u8 { - let state_ref = unsafe { &mut *state }; ARENA.with(|a| unsafe { - let arena = &mut *a.get(); + let arena_ptr = a.get(); // Sync inline-state offset back to underlying block (so // arena_walk_objects and the slow-path GC trigger see the - // post-burst offset). - arena.blocks[arena.current].offset = state_ref.offset; + // post-burst offset). Borrows stay short-lived: the GC inside + // `arena_cell_alloc` mutates this same arena (#7022). + let offset = (*state).offset; + { + let arena = &mut *arena_ptr; + let current = arena.current; + arena.blocks[current].offset = offset; + } // Allocate via existing path (may push a new block + run GC). - let ptr = arena.alloc(size, align); + let ptr = crate::arena::arena_cell_alloc(arena_ptr, size, align); // Resync inline state to the (possibly new) current block. - let block = &arena.blocks[arena.current]; - state_ref.data = block.data; - state_ref.offset = block.offset; - state_ref.size = block.size; + let (data, block_offset, block_size) = { + let arena = &*arena_ptr; + let block = &arena.blocks[arena.current]; + (block.data, block.offset, block.size) + }; + let state_ref = &mut *state; + state_ref.data = data; + state_ref.offset = block_offset; + state_ref.size = block_size; ptr }) } diff --git a/crates/perry-runtime/src/arena/mod.rs b/crates/perry-runtime/src/arena/mod.rs index 20ef961d4a..1340a42701 100644 --- a/crates/perry-runtime/src/arena/mod.rs +++ b/crates/perry-runtime/src/arena/mod.rs @@ -27,9 +27,13 @@ pub(crate) use allocators::{ inactive_survivor_index, with_survivor_arena, with_survivor_arena_mut, }; pub(crate) use block::{ - old_gen_in_use_bytes_sub, Arena, ArenaBlock, ACTIVE_SURVIVOR, ARENA, ARENA_TOTAL_BYTES, - BLOCK_SIZE, FRESH_GENERAL_BLOCK_MIN_USED_BYTES, INLINE_STATE, LONGLIVED_ARENA, OLD_ARENA, - OLD_GEN_IN_USE_BYTES, SURVIVOR_ARENA_0, SURVIVOR_ARENA_1, + arena_cell_alloc, old_gen_in_use_bytes_sub, Arena, ArenaBlock, ACTIVE_SURVIVOR, ARENA, + ARENA_TOTAL_BYTES, BLOCK_SIZE, FRESH_GENERAL_BLOCK_MIN_USED_BYTES, INLINE_STATE, + LONGLIVED_ARENA, OLD_ARENA, OLD_GEN_IN_USE_BYTES, SURVIVOR_ARENA_0, SURVIVOR_ARENA_1, +}; +#[cfg(test)] +pub(crate) use block::{ + gc_trigger_arena_borrow_depth, gc_trigger_arena_calls, reset_gc_trigger_arena_probe, }; pub(crate) use page_meta::{ address_span_overlaps_pages, register_block_space, register_old_object_pages, diff --git a/crates/perry-runtime/src/arena/tests.rs b/crates/perry-runtime/src/arena/tests.rs index 85bf6ca205..d30a26b20c 100644 --- a/crates/perry-runtime/src/arena/tests.rs +++ b/crates/perry-runtime/src/arena/tests.rs @@ -1018,3 +1018,55 @@ fn lazy_regions_defer_initial_block_allocation() { ); }); } + +// --------------------------------------------------------------------------- +// #7022: the allocation-point GC trigger must not run under a live `&mut Arena` +// borrow. +// +// `gc_check_trigger()` collects, and a collection ALLOCATES INTO THE ARENAS: +// promotion and C4b evacuation call `arena_alloc_gc_old`, an evacuating minor +// (#7019, default-on) fills a survivor semispace, and either can reach +// `Arena::install_fresh_block` → `self.blocks.push(..)` on the *same* arena the +// allocating frame is holding. A `Vec` growth there frees the buffer the outer +// frame then indexes, and `&mut` carries `noalias`, so the outer frame is also +// entitled to have cached `blocks.ptr`/`len` across the call. +// +// The two tests below pin the split that removes the hazard: the trigger lives +// in `arena_cell_alloc` (raw pointer, borrows re-derived per statement) and NOT +// in `Arena::alloc` (`&mut self`). +// --------------------------------------------------------------------------- + +#[test] +fn allocation_point_gc_trigger_runs_with_no_live_arena_borrow() { + reset_gc_trigger_arena_probe(); + // Larger than any existing block, so the current-block fast path must miss + // and the slow path (the one that collects) is guaranteed to run. + let ptr = OLD_ARENA.with(|a| unsafe { arena_cell_alloc(a.get(), BLOCK_SIZE + 1, 8) }); + assert!(!ptr.is_null(), "forced slow-path old-gen allocation failed"); + assert!( + gc_trigger_arena_calls() > 0, + "the forced slow path must reach the allocation-point GC trigger; \ + without it this test asserts nothing" + ); + assert_eq!( + gc_trigger_arena_borrow_depth(), + 0, + "gc_check_trigger() ran while an `&mut Arena` borrow was live — the \ + collector allocates into this same arena and may reallocate its \ + `blocks` Vec underneath the borrow (#7022)" + ); +} + +#[test] +fn raw_arena_alloc_method_never_reaches_the_gc_trigger() { + reset_gc_trigger_arena_probe(); + let ptr = OLD_ARENA.with(|a| unsafe { (*a.get()).alloc(BLOCK_SIZE + 1, 8) }); + assert!(!ptr.is_null(), "forced slow-path old-gen allocation failed"); + assert_eq!( + gc_trigger_arena_calls(), + 0, + "`Arena::alloc(&mut self, ..)` must stay collection-free: it is called \ + with a live borrow, so a GC trigger inside it re-enters the arena \ + (#7022). The trigger belongs in `arena_cell_alloc`." + ); +} From ebf4d3e737ff4a470a4f7783b44bbc4f616dde29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 30 Jul 2026 10:09:17 +0200 Subject: [PATCH 2/3] docs(changelog): #7050 allocation-point GC trigger outside the arena borrow Claude-Session: https://claude.ai/code/session_01G4k1vE6PVb53m2dRZ2aDtv --- ...tion-point-trigger-outside-arena-borrow.md | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 changelog.d/7050-gc-allocation-point-trigger-outside-arena-borrow.md diff --git a/changelog.d/7050-gc-allocation-point-trigger-outside-arena-borrow.md b/changelog.d/7050-gc-allocation-point-trigger-outside-arena-borrow.md new file mode 100644 index 0000000000..21eb5b8fd7 --- /dev/null +++ b/changelog.d/7050-gc-allocation-point-trigger-outside-arena-borrow.md @@ -0,0 +1,81 @@ +### `fix(gc)`: run the allocation-point GC trigger outside the `&mut Arena` borrow (#7022) + +`test_gap_repsel_gc_stress` SIGSEGV'd deterministically in the 12 +`gc_repsel_matrix.sh` arms carrying `PERRY_CONSERVATIVE_STACK_SCAN=off` — the +arms in which #7019's default-on evacuating young-gen scavenge is eligible at +all. The fault landed inside mimalloc's `realloc`, reached from the collector's +own `Vec` growth (`ValidPointerSetBuilder::step` → `RawVec::grow_one`): the +malloc heap was already corrupt on arrival. + +**Root cause.** `Arena::alloc(&mut self, ..)` called `crate::gc::gc_check_trigger()` +from inside its own borrow. A collection *allocates into the arenas* — promotion +and C4b evacuation call `arena_alloc_gc_old`, an evacuating minor fills a +survivor semispace — and either can reach `Arena::install_fresh_block` → +`self.blocks.push(..)` on the **same** `Arena` the allocating frame is holding. +The `Vec` growth frees the buffer the outer frame goes on to index +(`self.blocks[idx]`, `for i in 0..self.blocks.len()`), and `&mut` carries +`noalias`, so the outer frame is equally entitled to have cached +`blocks.ptr`/`len` across the call. + +Instrumented on the reproducer this is direct and frequent: 204 re-entries in a +single run, including `install_fresh_block gen=Old space=Old` while +`Arena::alloc` on the old arena holds the borrow, with `self.blocks`'s length +changing underneath it. The crashing stack is that shape exactly: +`js_array_grow → arena_alloc_gc_old → Arena::alloc → gc_check_trigger → +gc_collect_full_mark_sweep_with_trigger → … → _mi_theap_realloc_zero`. + +The hazard predates #7019 but was **latent**: before the moving minor, a +collection triggered from an allocation point did not itself install arena blocks +anywhere near this often. That is why this is a PASS → FAIL correctly attributed +to #7019 without #7019 containing the defect, and it explains the discriminator — +the copying minor is only eligible when the conservative stack scan is off. + +**Fix.** `arena_cell_alloc(*mut Arena, size, align)` is the new collecting entry +point: try the current block under a borrow that ends with the statement, run +`gc_check_trigger()` with **no arena borrow live**, then re-derive a fresh borrow +for the slow path. `Arena::alloc(&mut self, ..)` is now collection-free, and the +five thread-local entry points (`arena_alloc`, `arena_alloc_longlived`, +`arena_alloc_old`, `arena_alloc_gc_survivor`, `js_inline_arena_slow_alloc`) route +through it. The two that also touch `INLINE_STATE` keep those borrows short for +the same reason — `Arena::resync_inline_to_current` mutates `INLINE_STATE` from +inside the collection. When the GC runs is unchanged. + +**Measured.** Same commit, same compiler, only the four arena files differing; +release build, auto-optimized binaries (with `PERRY_NO_AUTO_OPTIMIZE=1` the crash +does not reproduce at all, which is why the harness deliberately does not set +it); macOS arm64, node 26.5.0; `PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 +PERRY_CONSERVATIVE_STACK_SCAN=off`. + +| `PERRY_GC_SCAVENGE_NURSERY_MB` | before | after | +|---|---|---| +| 1, 2, 3, 4, 5, 6, 8, 10, 12, 24, 32, 64 (× 3) | 0 | 0 | +| **16** (the default), × 20 | **139 × 20** | **0 × 20**, byte-exact vs node | +| default, no override, × 5 | **139 × 5** | **0 × 5** | + +`PERRY_GC_FORCE_EVACUATE=1`, `+ PERRY_GC_VERIFY_EVACUATION=1` and +`PERRY_GC_FROMSPACE_SCAN=1` on top of the same base: 5/5 crashes before, 0/5 +after. `cargo test --release -p perry-runtime --lib -- --test-threads=1`: 1521 +passed, 0 failed. + +**Regression coverage** (`cargo-test`-visible, per #5960): +`arena::tests::allocation_point_gc_trigger_runs_with_no_live_arena_borrow` pins +that the trigger reached from `arena_cell_alloc` sees an arena-borrow depth of 0 +(and that it was reached at all, so the test cannot pass vacuously); +`arena::tests::raw_arena_alloc_method_never_reaches_the_gc_trigger` pins that +`Arena::alloc` stays collection-free, so a refactor cannot move the trigger back +under the borrow. The borrow-depth probe is `cfg(test)`-only — production +allocation pays nothing. Teeth verified by sabotage: reinstating either half of +the old shape turns the matching test red. + +**Investigation note.** #7022's dossier classified this as a missing rewrite of +an old→young remembered-set edge, from `PERRY_GC_FROMSPACE_SCAN`'s +`lost_dirty`/`dirty_but_missed` split. That classification was measuring noise: +extending the scan with two axes — is the offender's *owner* itself +`GC_FLAG_FORWARDED`, and is the slot inside the collector's own rewrite +enumeration — shows `enumerated=0` on every cycle of every run, with 99%+ of +offenders being the dead payload of `js_array_grow`'s permanent growth stubs +(#233), the rest uninitialized bytes inside an array's unused capacity (arena +blocks are recycled, not zeroed, and `move_young` copies the whole payload) and +unaligned words that `CopyingPointerSet::decode_bits` rejects by construction. +The scan's offender counts are unchanged by this fix. See #7050 for the full +split; a follow-up against #7041/#7035 will teach the instrument to report it. From 2ba471a8ce5c9af8e2fe87ad97ef5a81a0293651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 30 Jul 2026 10:15:37 +0200 Subject: [PATCH 3/3] docs(changelog): record the #7050 matrix delta (PASS 302->324, FAIL 46->24) Claude-Session: https://claude.ai/code/session_01G4k1vE6PVb53m2dRZ2aDtv --- ...50-gc-allocation-point-trigger-outside-arena-borrow.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/changelog.d/7050-gc-allocation-point-trigger-outside-arena-borrow.md b/changelog.d/7050-gc-allocation-point-trigger-outside-arena-borrow.md index 21eb5b8fd7..cf665c480c 100644 --- a/changelog.d/7050-gc-allocation-point-trigger-outside-arena-borrow.md +++ b/changelog.d/7050-gc-allocation-point-trigger-outside-arena-borrow.md @@ -57,6 +57,14 @@ PERRY_CONSERVATIVE_STACK_SCAN=off`. after. `cargo test --release -p perry-runtime --lib -- --test-threads=1`: 1521 passed, 0 failed. +**Matrix.** `scripts/gc_repsel_matrix.sh --arms all --pressure 8`, 440 cells: +`PASS=324 UNVER=91 XFAIL=1 FAIL=24`, from the `PASS=302 UNVER=91 XFAIL=1 FAIL=46` +baseline — **+22 PASS / -22 FAIL, no cell regressed**. `repsel_gc_stress` is PASS +in all 20 arms (was FAIL in 12), and so is `repsel_scalar_replaced_locals` +(#7023, was intermittently FAIL in 11) — the same defect. The residual 24 FAILs +are the two #6981 `p4a3` numarray rows, unchanged. Liveness is intact: +`evac_minor`/`force_evac`/`force_verify` still report `copy-minor 21/22`. + **Regression coverage** (`cargo-test`-visible, per #5960): `arena::tests::allocation_point_gc_trigger_runs_with_no_live_arena_borrow` pins that the trigger reached from `arena_cell_alloc` sees an arena-borrow depth of 0