Skip to content

fix(gc): minor sweep must not finalize unmarked old-gen objects (#6892) - #6933

Merged
proggeramlug merged 2 commits into
mainfrom
fix/6892-minor-gc-missing-barrier
Jul 28, 2026
Merged

fix(gc): minor sweep must not finalize unmarked old-gen objects (#6892)#6933
proggeramlug merged 2 commits into
mainfrom
fix/6892-minor-gc-missing-barrier

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Fixes #6892.

Root cause

A minor trace never marks the old generation — old-gen parents are black
leaves, reached only through dirty remembered-set pages. So in a minor,
"unmarked" means unvisited, not dead.

The sweep already encodes this rule for forwarding stubs
(retain_all_forwarded_stubs), and cycle.rs states it outright when it
builds the sweep state:

Minor traces never mark the old generation, so deadness there is only
trusted for untenured nursery headers.

But ArenaSweepObjectsState::process_object did not honour it for ordinary
objects — every unmarked old-gen object took the reclaim_dead_object path.

A minor never frees old-gen memory, which is why this stayed latent. The
damage is the side effects: reclaim_dead_object runs
finalize_dead_arena_payload, which calls layout_clear_for_ptr and drops
the object's entry from LAYOUT_SLOT_MASKS (along with its payload side
tables and external payload buffers).

That leaves a live old-gen object in state GC_LAYOUT_SIDE_MASK with no
mask. The next layout_note_slot on it rebuilds a mask from that one slot,
so the following trace visits only that element and skips every other
pointer slot in the object. The children behind those slots are then swept
while still referenced; the mutator reads a recycled address and gets
undefined. That is why the reported symptom was a varying field name —
'tag', 'name', 'span', 'isExtern' — rather than one specific site.

How it was localized

Instrumented builds, each step narrowing the blast radius on the issue's
reproducer:

  1. Both existing verifiers were clean at the minor's mark→sweep boundary
    (verify_minor_unmarked_young_children_report,
    verify_marked_heap_report_nonfatal), and so was a widened verifier that
    also covered the parent classes they miss (Longlived-arena and
    malloc-tracked parents, which survive a minor without being marked):
    387k parents, zero dropped edges. So marking was not the problem, and
    this is not a missing write barrier.
  2. Suppressing the arena sweep's object reclaim made the failure vanish.
  3. Bisecting reclaim_dead_object's side effects isolated
    layout_clear_for_ptr, then the LAYOUT_SLOT_MASKS removal specifically.
  4. A histogram over every mask removal named the victims directly:
    OLD:array(ty=1,flags=0x22)=8 — eight ARENA|TENURED, unmarked, live
    old-generation arrays wiped during a minor sweep.

Fix

unmarked_is_provably_dead(): an unmarked object is only provably dead when
the trace covered its generation. Old-gen blocks are exempt during a minor
sweep — except blocks selected for old-page defrag this cycle, whose live
contents were evacuated during that same cycle (and whose block-level reclaim
needs block_has_live to stay false for them).

Full traces are unchanged: they visit every live parent, so mark-based old-gen
reclaim stays sound there. retain_all_forwarded_stubs is renamed to
minor_sweep — it is what every call site already passes, and both rules now
key off it.

This also makes the arena object sweep consistent with every other
post-trace finalizer
, which already applied exactly this rule and were the
reason the bug stayed narrow: registered_map_is_dead_post_trace (and the
Set / buffer / typed-array twins) and dead_owner::owner_is_dead all refuse
to trust an unmarked header on a non-full trace unless it is an untenured
nursery object. ArenaSweepObjectsState::process_object was the one place
that skipped the check.

Validation

Reproducer: the Milo compiler built by Perry, running emit-ir over a file
importing std/fetch.

mode before after
default throws pass, IR byte-identical to bun
PERRY_GEN_GC=0 pass pass
PERRY_WRITE_BARRIERS=0 pass pass
PERRY_GEN_GC_EVACUATE=0 throws pass
PERRY_GC_VERIFY_EVACUATION=1 throws pass
PERRY_GC_FORCE_EVACUATE=1 throws pass

PERRY_GC_FORCE_EVACUATE=1 + PERRY_GC_VERIFY_EVACUATION=1 together: pass,
no verifier panic.

Milo's example corpus, Perry-built compiler vs the same compiler under bun,
byte-comparing emit-ir output: 51/53 identical.

Regression tests (gc::tests::oldgen):

  • test_minor_sweep_keeps_unmarked_old_object_layout_mask — fails on the
    parent commit, passes with the fix.
  • test_full_sweep_still_finalizes_unmarked_old_object — the converse, so
    the fix can't be widened into "never reclaim the old generation".

cargo test -p perry-runtime -- --test-threads=1: 1478 passed, 0 failed.
(The default parallel run has 4–5 pre-existing failures in
gc::tests::teardown, global_this_webassembly and native_module_stream;
they reproduce identically with this fix patched out.)

cargo fmt --all -- --check: clean.

Not fixed by this PR

Two milo examples still differ, and neither is this bug. Both fail under
every GC arm including PERRY_GEN_GC=0 (full mark-sweep, no generational
collector at all), so neither is a generational-GC liveness defect:

  • examples/cli-tools/fmt.miloCannot read properties of undefined (reading 'name'), deterministic across repeated runs and every arm.
  • examples/net/serve.milo — same shape. The reported field still shifts
    between arms ('tag' vs 'attributes'), so something upstream of it is
    allocation-timing sensitive, but the failure itself survives turning the
    generational collector off entirely.

Filed separately rather than folded in here — they are a different defect and
this PR's reproducer is byte-clean.

None of the recent elision PRs (#6919, #6911, #6915, #6916) contributed: the
bug predates all of them (reported at fd7475182), the fault is in the
runtime sweep rather than in barrier emission, and no barrier was elided for
a value that is a pointer.

Perf note

Barrier emission density is unchanged. One behavioural note: minors
previously fed old_page_account_swept_object(live=false) for every unmarked
old object — phantom dead bytes derived from mark bits that say nothing about
the old generation. They now report those objects live, so the old-page
defrag selector's dead-byte signal comes from full traces, where it is real.

A minor trace never marks the old generation: old-gen parents are black
leaves, reached only through dirty remembered-set pages. "Unmarked" in a
minor therefore means "unvisited", not "dead" — the sweep's own
`retain_all_forwarded_stubs` rule already spells this out for forwarding
stubs, and cycle.rs says it again ("Minor traces never mark the old
generation, so deadness there is only trusted for untenured nursery
headers"). `ArenaSweepObjectsState::process_object` did not honour it for
ordinary objects: any unmarked old-gen object took the `reclaim_dead_object`
path.

A minor never frees old-gen memory, so this looked harmless — but
`reclaim_dead_object` still runs `finalize_dead_arena_payload` on the
object, and that calls `layout_clear_for_ptr`, which drops the object's
entry from `LAYOUT_SLOT_MASKS` (plus its payload side tables and external
payload buffers). The live old-gen object is left in state
`GC_LAYOUT_SIDE_MASK` with no mask. The next `layout_note_slot` on it
rebuilds a mask from that single slot, so the following trace visits only
that one element and skips every other pointer slot — and the children
behind them are swept while still referenced. The mutator then reads a
recycled address and gets `undefined`, which is why the reported symptom
was a varying `Cannot read properties of undefined (reading '<field>')`.

Fix: `unmarked_is_provably_dead()` — an unmarked object is only provably
dead when the trace covered its generation. Old-gen blocks are exempt in a
minor sweep, except blocks selected for old-page defrag this cycle, whose
live contents were evacuated during the same cycle (and whose block-level
reclaim needs `block_has_live` to stay false). Full traces are unchanged:
they visit every live parent, so mark-based old-gen reclaim stays sound.

Renames `retain_all_forwarded_stubs` to `minor_sweep`, which is what every
call site already passes and what both rules now key off.

Repro (issue #6892): the Milo compiler built by Perry, running
`emit-ir` over a file importing `std/fetch`. Before: throws. After: exits 0
and its LLVM IR is byte-identical to the same compiler under bun. The
env matrix that motivated the report (`PERRY_GEN_GC=0` and
`PERRY_WRITE_BARRIERS=0` pass, `PERRY_GEN_GC_EVACUATE=0` and
`PERRY_GC_VERIFY_EVACUATION=1` fail) is now uniformly green, as are
`PERRY_GC_FORCE_EVACUATE=1` and the two stress flags combined.

Note on old-page defrag input: minors previously fed
`old_page_account_swept_object(live=false)` for every unmarked old object,
i.e. phantom dead bytes derived from mark bits that say nothing about the
old generation. They now report those objects live, so the defrag
selector's dead-byte signal comes from full traces, where it is real.
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@proggeramlug, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b548d9b5-b223-48e1-a988-cb9a8b917e5c

📥 Commits

Reviewing files that changed from the base of the PR and between 6cdf39d and 9a22272.

📒 Files selected for processing (3)
  • changelog.d/6933-minor-sweep-old-gen-finalize.md
  • crates/perry-runtime/src/gc/oldgen.rs
  • crates/perry-runtime/src/gc/tests/oldgen.rs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/6892-minor-gc-missing-barrier

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug
proggeramlug merged commit d766897 into main Jul 28, 2026
32 of 33 checks passed
@proggeramlug
proggeramlug deleted the fix/6892-minor-gc-missing-barrier branch July 28, 2026 17:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GC: live object swept during minor GC — missing write barrier (PERRY_WRITE_BARRIERS=0 / PERRY_GEN_GC=0 both fix it)

1 participant