Skip to content

gc: the emergency-reclaim path still runs a collection inside an &mut Arena borrow (#7050 follow-up) #7051

Description

@proggeramlug

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_blockself.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::allocgc_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:

  1. under a borrow: everything that does not need a fresh block (retry current, scan the other blocks);
  2. drop the borrow; allocate the fresh block — this is where alloc_block may run gc_try_emergency_reclaim;
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions