Summary
PR #7050 fixed the allocation-point GC trigger running inside an &mut Arena borrow. The same aliasing violation survives on the emergency-reclaim path, which that PR does not touch.
Found by CodeRabbit on #7050 (🟠 Major, crates/perry-runtime/src/arena/block.rs:481), verified against the branch head 2ba471a8c before filing.
The path
arena_cell_alloc
└─ ArenaBorrowGuard::new() <-- borrow taken
└─ Arena::alloc_after_gc(&mut self)
└─ alloc_fresh_block(&mut self)
└─ install_fresh_block(&mut self)
└─ alloc_block(size) [free fn]
└─ alloc(layout) returns NULL
└─ crate::gc::gc_try_emergency_reclaim() <-- full collection
gc_try_emergency_reclaim runs a full collection. 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 install_fresh_block → self.blocks.push(..) on the same Arena whose &mut borrow is live several frames up.
That Vec growth frees the buffer the outer frames go on to index (self.blocks[self.current], for i in 0..self.blocks.len()). &mut carries noalias, so the outer frame is equally entitled to have cached blocks.ptr/len across the call.
This is byte-for-byte the mechanism #7050 diagnosed and fixed for Arena::alloc → gc_check_trigger.
Severity
Narrower than #7022, but worse when it fires. It needs alloc(layout) to return NULL — genuine heap exhaustion — whereas #7022's path fired on every block-full trigger. But heap exhaustion is precisely when a corrupted arena is least survivable, and the failure mode is the same silent memory corruption that took ~450 agent tool-calls and six refuted hypotheses to diagnose the first time.
It is also, by construction, nearly impossible to hit in testing: you need the OS to refuse memory at exactly the moment a collection would install a block.
Suggested fix
The pattern is already in the tree from #7050 — split into a borrow-less stage and a borrow-only stage:
- under a borrow: everything that does not need a fresh block (retry current, scan the other blocks);
- drop the borrow; allocate the fresh block — this is where
alloc_block may run gc_try_emergency_reclaim;
- re-borrow only to install the pre-allocated block and perform the final allocation.
Note the shape matters: alloc_after_gc must not pre-allocate unconditionally, or every slow-path call wastes a 1 MiB block. It needs to determine that a fresh block is required, return that signal, and let the caller allocate it borrow-free.
alloc_fresh_block_excluding_pages reaches install_fresh_block too and should be audited in the same pass.
Acceptance
#7050 added a cfg(test)-only borrow-depth probe (gc_trigger_arena_borrow_depth()) with two tests asserting depth 0 at the trigger. Extending that probe to cover this path is worth more than the fix itself — it turns "we fixed the two places we found" into "the invariant is asserted wherever a collection can be entered".
If the probe cannot reach this path (it requires a NULL alloc), say so explicitly rather than leaving an impression of coverage. A fault-injection hook that forces alloc_block's first alloc to return NULL would make it reachable.
Why this is filed rather than folded into #7050
#7050 fixes a deterministic crash, is verified across a 13-cap sweep, a 20-run A/B, the full 440-cell matrix (PASS 302→324, FAIL 46→24, nothing regressed) and 1521 runtime tests, and it closes #7023 as well. Holding it for an OOM-only path would delay a fix the collector needs now.
This path deserves the same standard of verification, which means it deserves its own change.
Summary
PR #7050 fixed the allocation-point GC trigger running inside an
&mut Arenaborrow. The same aliasing violation survives on the emergency-reclaim path, which that PR does not touch.Found by CodeRabbit on #7050 (🟠 Major,
crates/perry-runtime/src/arena/block.rs:481), verified against the branch head2ba471a8cbefore filing.The path
gc_try_emergency_reclaimruns a full collection. A collection allocates into the arenas — promotion and C4b evacuation callarena_alloc_gc_old, an evacuating minor fills a survivor semispace — and either can reachinstall_fresh_block→self.blocks.push(..)on the sameArenawhose&mutborrow is live several frames up.That
Vecgrowth frees the buffer the outer frames go on to index (self.blocks[self.current],for i in 0..self.blocks.len()).&mutcarriesnoalias, so the outer frame is equally entitled to have cachedblocks.ptr/lenacross the call.This is byte-for-byte the mechanism #7050 diagnosed and fixed for
Arena::alloc→gc_check_trigger.Severity
Narrower than #7022, but worse when it fires. It needs
alloc(layout)to return NULL — genuine heap exhaustion — whereas #7022's path fired on every block-full trigger. But heap exhaustion is precisely when a corrupted arena is least survivable, and the failure mode is the same silent memory corruption that took ~450 agent tool-calls and six refuted hypotheses to diagnose the first time.It is also, by construction, nearly impossible to hit in testing: you need the OS to refuse memory at exactly the moment a collection would install a block.
Suggested fix
The pattern is already in the tree from #7050 — split into a borrow-less stage and a borrow-only stage:
alloc_blockmay rungc_try_emergency_reclaim;Note the shape matters:
alloc_after_gcmust not pre-allocate unconditionally, or every slow-path call wastes a 1 MiB block. It needs to determine that a fresh block is required, return that signal, and let the caller allocate it borrow-free.alloc_fresh_block_excluding_pagesreachesinstall_fresh_blocktoo and should be audited in the same pass.Acceptance
#7050 added a
cfg(test)-only borrow-depth probe (gc_trigger_arena_borrow_depth()) with two tests asserting depth 0 at the trigger. Extending that probe to cover this path is worth more than the fix itself — it turns "we fixed the two places we found" into "the invariant is asserted wherever a collection can be entered".If the probe cannot reach this path (it requires a NULL
alloc), say so explicitly rather than leaving an impression of coverage. A fault-injection hook that forcesalloc_block's firstallocto return NULL would make it reachable.Why this is filed rather than folded into #7050
#7050 fixes a deterministic crash, is verified across a 13-cap sweep, a 20-run A/B, the full 440-cell matrix (
PASS 302→324, FAIL 46→24, nothing regressed) and 1521 runtime tests, and it closes #7023 as well. Holding it for an OOM-only path would delay a fix the collector needs now.This path deserves the same standard of verification, which means it deserves its own change.