Skip to content

fix(gc): allocate-black births were never traced — children reachable only through them were swept live - #6494

Merged
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:fix/allocate-black-untraced-births
Jul 17, 2026
Merged

fix(gc): allocate-black births were never traced — children reachable only through them were swept live#6494
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:fix/allocate-black-untraced-births

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Symptom

A large esbuild-bundled TUI app (React-based) intermittently loses constructor-initialized object fields at runtime: reads of alternate / lanes / dependencies on live React fibers return undefined, and a cached Set's iteration yields undefined (Cannot read properties of undefined (reading 'slice')). ~50% repro on a scripted PTY run, GC-mode-insensitive (default / PERRY_GC_FORCE_EVACUATE=1 / PERRY_GEN_GC_EVACUATE=0 identical).

Diagnosed with an env-gated side-table tracer: a live object's overflow entry was cleared by the sweep (clear_overflow_for_ptr) and then read moments later through a still-held reference — the object was swept while alive. Its payload stayed readable (inline fields fine, tag intact), which is why the failure surfaced as "some fields are undefined" rather than a crash.

Root cause

Budgeted cycles set the allocate-black birth flag (GC_BIRTH_EXTRA_FLAGS = MARKED) at cycle construction, but the insertion barrier only engages at the end of BuildValidPointerSet. An object born in a build-phase mutator window is:

  • born MARKED → marking treats it as already-visited → never traced;
  • storing children with the barrier off → nothing shades them.

A child linked into such an object before barrier-enable — and reachable through nothing else — stays white for the entire cycle and is swept live.

React hits this shape deterministically: work-in-progress fibers are created in bursts (runtime construct path → born black) and immediately cross-linked (wip.alternate = current). After a commit swap, the old fiber tree is reachable only via those alternate back-edges, so a whole live tree was freed one phase later (reverse-reference scan confirmed: victims' only marked referrer held them via an overflow slot, and that referrer had zero trace events — born-black, never traced).

Two adjacent windows had the same consequence and are fixed together:

  • finalize→sweep gap: the barrier was disabled at AtomicFinalize-end, but the sweep state's block-fill snapshot is only built on the first step_sweep slice — one or more mutator windows later. Gap-born objects sat white inside the snapshot.
  • stale birth marks: birth flags stayed set through the sweep's mutator windows, but sweep-phase births are beyond the block-fill snapshot, so that sweep never visits them and never clears their mark — they entered the next cycle pre-marked ("already traced"), re-creating the untraced-black-birth hole.

Fix

  1. gc_note_black_birth — every born-black runtime allocation (6 arena allocator sites + 2 malloc sites) is also pushed as a mark seed; the trace drains (which already absorb seeds every step) descend into it and mark its children. Preserves the GC: incremental mutator assists use a fixed budget — allocation outruns the collector, RSS grows unbounded (6-22x measured) #6224 born-marked contract for raw-installed runtime buffers.
  2. Barrier lifetime — stays enabled across the AtomicFinalize→Sweep boundary; the first step_sweep slice drains the gap's shaded seeds, disables the barrier, and builds the block snapshot atomically (no mutator window between barrier-off and snapshot).
  3. Allocate-black lifetime — ends when the barrier disables (sweep entry) instead of at outcome finalization. Sweep-phase births are snapshot-safe by construction (arena cursor and malloc sweep are both snapshot-bounded at sweep-state creation) and no longer carry a stale mark into the next cycle.

Tests

Three regression tests, each validated failing-then-passing by temporarily reverting the corresponding change:

  • born_black_build_phase_object_is_traced — an unrooted born-black build-phase object's only-child must survive the cycle (fails without the mark seed; block-persistence window aged out to unmask the sweep).
  • gap_born_child_stored_between_finalize_and_sweep_survives — a white child linked from a live parent in the finalize→sweep gap must survive (fails with the early barrier disable).
  • full_atomic_finalize_slices_barrier_seed_drain_with_tiny_budget — contract updated: barrier active across the finalize→sweep boundary, off after the first sweep slice.

perry-runtime suite: 1323 passed / 0 failed (--test-threads=1).

End-to-end on the TUI reproducer: 6/6 scripted PTY runs with zero visible errors and zero sweep-then-read incidents (previously 5–6/6 failing, 16–20 live fibers freed per incident, deterministic at the same sweep).

Cost

One Vec push per runtime allocation while a budgeted cycle is active (seeds are deduplicated by the born-marked bit and drained incrementally); born-black floating garbage now also gets traced, keeping its children one extra cycle — same retention class as allocate-black itself.

https://claude.ai/code/session_01M44HoYS3CAYZyagm3ZzYDG

Summary by CodeRabbit

  • Bug Fixes
    • Improved “born-black” barrier handling by recording newly allocated objects for correct incremental marking.
    • Fixed edge cases where objects could be missed during transitions between atomic finalization and sweeping.
    • Corrected barrier shutdown behavior to avoid birth-state leaking across sweeps.
  • Tests
    • Enhanced cycle failure diagnostics with phase histograms.
    • Added incremental-marking regression tests covering born-black and gap-born survival across finalize→sweep.
    • Updated existing barrier coverage to verify barrier activity timing across the gap.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Arena and malloc GC allocation paths now record black births. Barrier birth flags are cleared at sweep entry, while barrier-seeded work is drained before the sweep snapshot. New tests cover born-black tracing and allocations during the finalize-to-sweep gap.

Changes

Incremental GC barrier lifecycle

Layer / File(s) Summary
Black birth bookkeeping
crates/perry-runtime/src/gc/barrier.rs
Barrier shutdown clears birth flags, and marked-at-birth objects are pushed as mark seeds.
Allocation birth hooks
crates/perry-runtime/src/arena/allocators.rs, crates/perry-runtime/src/gc/malloc.rs
Arena, free-list, fresh, and batch allocation paths note black births after header initialization.
Finalize-to-sweep barrier timing
crates/perry-runtime/src/gc/cycle.rs
Barrier disabling moves from atomic-finalize paths to sweep entry, after remaining barrier seeds are drained and before the sweep snapshot.
Barrier and sweep regression coverage
crates/perry-runtime/src/gc/tests/cycle_state.rs, crates/perry-runtime/src/object/mod.rs
Tests verify born-black tracing, finalize-to-sweep gap allocations, timeout phase diagnostics, and barrier state at the first sweep slice.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant gc_malloc
  participant gc_note_black_birth
  participant step_atomic_finalize_current_subphase
  participant step_sweep
  gc_malloc->>gc_note_black_birth: Initialize and note marked GcHeader
  step_atomic_finalize_current_subphase->>step_atomic_finalize_current_subphase: Drain barrier-seeded work
  step_sweep->>step_sweep: Drain remaining barrier seeds
  step_sweep->>step_sweep: Disable barrier while creating sweep snapshot
Loading

Possibly related PRs

Suggested reviewers: andrewtdiz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the GC bug fix and the born-black tracing behavior addressed by the PR.
Description check ✅ Passed The description covers the symptom, root cause, fix, and tests, so it is substantively complete despite not matching the template headings.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/gc/tests/cycle_state.rs`:
- Around line 718-788: Prevent allocation-time trace seeds from masking both GC
regressions: in crates/perry-runtime/src/gc/tests/cycle_state.rs lines 718-788,
allocate the white child before starting the cycle so only tracing through the
born-black parent preserves it; in lines 790-847, use a hook-free
codegen-equivalent allocation or remove the child’s birth seed before the
barriered store. Update the affected regression tests without changing their
intended assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1f49ef04-9023-4409-86ff-f5b5f63305c9

📥 Commits

Reviewing files that changed from the base of the PR and between 77def53 and 98d3bd0.

📒 Files selected for processing (6)
  • crates/perry-runtime/src/arena/allocators.rs
  • crates/perry-runtime/src/gc/barrier.rs
  • crates/perry-runtime/src/gc/cycle.rs
  • crates/perry-runtime/src/gc/malloc.rs
  • crates/perry-runtime/src/gc/tests/cycle_state.rs
  • crates/perry-runtime/src/object/mod.rs

Comment thread crates/perry-runtime/src/gc/tests/cycle_state.rs
@proggeramlug

Copy link
Copy Markdown
Contributor Author

CI triage for the red checks — none are caused by this PR:

On this branch, perry-runtime is 1323/0 single-threaded, and the seeding hot-path concern surfaced by
full_cycle_bound_prototype_method_cache_after_root_scan_marks_new_value is addressed in 7ff8d47 (pointer-free births are not seeded — leaf types have no child edges to trace).

Ralph Küpper added 2 commits July 17, 2026 09:26
… only through them were swept live

A budgeted cycle sets allocate-black birth flags at cycle construction, but
the insertion barrier only engages once BuildValidPointerSet completes. An
object born in a build-phase mutator window is therefore MARKED at birth
while its subsequent child stores are unshaded — and marking treats MARKED
as "already visited", so the object is never traced. A child linked into it
before barrier-enable and reachable through nothing else stays white for
the whole cycle and is swept live.

Observed in a large compiled TUI app: React creates work-in-progress fibers
in exactly that window and cross-links them (`wip.alternate = current`).
After a commit swap the old fiber tree is reachable ONLY through those
`alternate` back-edges, so an entire live tree was freed one phase later —
its objects' payloads stayed readable while their side tables (overflow
fields, Set/Map buffers) were cleared, surfacing as intermittent
`undefined` reads of constructor-initialized fields (`alternate`, `lanes`,
`dependencies`) and `Cannot read properties of undefined (reading 'slice')`
crashes at ~50% repro on a scripted PTY run.

Three coordinated changes:

1. `gc_note_black_birth`: every born-black runtime allocation (6 arena
   allocator sites + 2 malloc sites) is also pushed as a mark seed, so the
   trace drains descend into it and mark its children. This preserves the
   PerryTS#6224 born-marked contract (raw-installed runtime buffers held only in
   Rust locals still survive) while restoring the tracing the mark bit was
   suppressing.

2. The insertion barrier now stays enabled across the AtomicFinalize→Sweep
   boundary and is disabled inside the first step_sweep slice, right before
   the sweep state's block-fill snapshot is built — previously the barrier
   dropped at finalize-end while the snapshot was taken one or more mutator
   windows later, so a gap-born object sat white INSIDE the snapshot and
   was freed live. The first sweep slice drains the gap's shaded seeds,
   disables the barrier, and snapshots atomically.

3. Allocate-black now ends when the barrier disables (sweep entry) instead
   of at outcome finalization. Sweep-phase births cannot be reached by the
   in-flight sweep anyway (the arena cursor and the malloc sweep are both
   snapshot-bounded), while a birth mark they carried was never cleared by
   that sweep (post-snapshot objects are exactly the ones it never visits)
   and leaked into the next cycle as "already traced" — re-creating the
   untraced-black-birth hole one cycle later.

Three regression tests, each validated failing-then-passing:
- born_black_build_phase_object_is_traced — unrooted born-black object's
  only-child must survive (fails without the mark seed).
- gap_born_child_stored_between_finalize_and_sweep_survives — white child
  linked from a live parent in the finalize→sweep gap must survive (fails
  with the early barrier disable).
- full_atomic_finalize_slices_barrier_seed_drain_with_tiny_budget — updated
  contract: barrier active across the finalize→sweep boundary, off after
  the first sweep slice.

Verified end-to-end on the compiled TUI reproducer: 6/6 scripted PTY runs
with zero visible errors and zero sweep-then-read incidents (previously
5-6/6 failing runs with 16-20 live fibers freed per incident); perry-runtime
suite 1323/0 single-threaded.

Claude-Session: https://claude.ai/code/session_01M44HoYS3CAYZyagm3ZzYDG
…st self-contained

Post-CI refinements to the allocate-black tracing fix:

- `gc_note_black_birth` skips leaf types (`gc_type_is_pointer_free`): they
  carry no child edges, so the birth mark alone protects them. Without the
  skip, a lazy-init burst mid-cycle (the globalThis builtins table
  populating: thousands of interned strings) turned into pure seed-drain
  traffic — enough to blow the test harness's 100k single-unit step cap in
  `full_cycle_bound_prototype_method_cache_after_root_scan_marks_new_value`
  when run in isolation.

- The born-black regression test reclaims its filler blocks with a trailing
  full collection so later bounded-step tests don't walk seven extra blocks
  of dead strings, and cargo fmt over the touched files (the lint failure).

Claude-Session: https://claude.ai/code/session_01M44HoYS3CAYZyagm3ZzYDG
@proggeramlug
proggeramlug force-pushed the fix/allocate-black-untraced-births branch from 7ff8d47 to 1d005ac Compare July 17, 2026 07:30

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
crates/perry-runtime/src/gc/barrier.rs (2)

781-787: 📐 Maintainability & Code Quality | 🔵 Trivial

Rebuild the static wrapper crates.

As per coding guidelines, when changing runtime or stdlib code, rebuild the corresponding static wrapper crates (perry-runtime-static and perry-stdlib-static) because the runtime and stdlib crates themselves emit only rlibs and otherwise may leave stale archives linked.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/gc/barrier.rs` around lines 781 - 787, Rebuild the
corresponding static wrapper crates per the repository’s standard procedure:
perry-runtime-static and perry-stdlib-static. Ensure their generated static
archives reflect the runtime change near GC_BIRTH_EXTRA_FLAGS and are included
in the resulting changes.

Source: Coding guidelines


808-812: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Read the header flag directly to avoid a thread-local storage lookup.

Since all callers assign gc_birth_extra_flags() to (*header).gc_flags immediately before calling gc_note_black_birth, you can check the header's flags directly. This avoids a redundant thread-local storage access on the allocation hot path.

♻️ Proposed fix
 #[inline]
 pub(crate) fn gc_note_black_birth(header: *mut GcHeader) {
-    if GC_BIRTH_EXTRA_FLAGS.with(|cell| cell.get()) & GC_FLAG_MARKED == 0 {
+    if unsafe { (*header).gc_flags & GC_FLAG_MARKED } == 0 {
         return;
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/gc/barrier.rs` around lines 808 - 812, Update
gc_note_black_birth to inspect (*header).gc_flags for GC_FLAG_MARKED instead of
reading GC_BIRTH_EXTRA_FLAGS through thread-local storage. Preserve the early
return when the header is not marked, relying on callers having already assigned
gc_birth_extra_flags() to the header.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/perry-runtime/src/gc/barrier.rs`:
- Around line 781-787: Rebuild the corresponding static wrapper crates per the
repository’s standard procedure: perry-runtime-static and perry-stdlib-static.
Ensure their generated static archives reflect the runtime change near
GC_BIRTH_EXTRA_FLAGS and are included in the resulting changes.
- Around line 808-812: Update gc_note_black_birth to inspect (*header).gc_flags
for GC_FLAG_MARKED instead of reading GC_BIRTH_EXTRA_FLAGS through thread-local
storage. Preserve the early return when the header is not marked, relying on
callers having already assigned gc_birth_extra_flags() to the header.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a0700a12-5f75-4670-ba8a-6a416680f223

📥 Commits

Reviewing files that changed from the base of the PR and between 98d3bd0 and 1d005ac.

📒 Files selected for processing (6)
  • crates/perry-runtime/src/arena/allocators.rs
  • crates/perry-runtime/src/gc/barrier.rs
  • crates/perry-runtime/src/gc/cycle.rs
  • crates/perry-runtime/src/gc/malloc.rs
  • crates/perry-runtime/src/gc/tests/cycle_state.rs
  • crates/perry-runtime/src/object/mod.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/arena/allocators.rs
  • crates/perry-runtime/src/gc/cycle.rs
  • crates/perry-runtime/src/gc/tests/cycle_state.rs

proggeramlug pushed a commit to proggeramlug/perry that referenced this pull request Jul 17, 2026
…em (PerryTS#6495)

`visit_overflow_field_slots_mut` (mark-phase trace + rewrite walks) and
`scan_overflow_fields_roots_mut` consulted the per-object layout slot mask
to visit only pointer-bearing overflow slots. The mask is maintained by
`layout_note_slot` at store time — but not every overflow write path notes:
GC owner moves merge entries via `merge_overflow_fields` with no notes, so
a usable-looking SIDE_MASK can claim pointer-bearing overflow slots are
pointer-free. The trace then skips them, and a child reachable only
through such a slot is swept while referenced.

Observed at bundle scale in a large compiled TUI app (diagnostics from the
PerryTS#6494 investigation): 17 objects whose masks capped at bit 48 while slots
49..63 held live NaN-boxed heap pointers — ~3,200 skipped pointer-slot
visits per run.

Visit every overflow slot unconditionally. The Vec's length is the live
overflow region, and objects with large overflow populations sit in
UNKNOWN layout state in practice (every dynamic-shape store degrades the
layout), so the mask fast path bought little exactly where it would cost.
The inline-slot region keeps its layout-driven visit — its stores all
funnel through the noting choke points.

Regression test `overflow_slots_beyond_layout_mask_are_traced`: a rooted
object with a SIDE_MASK claiming slots 0..=48 and the only reference to a
live string sitting in overflow slot 50 (seeded without notes, the
merge_overflow_fields shape); asserts the string is MARKED at sweep entry.
Validated failing-then-passing against the old fast path (block-persistence
window aged out so the recent-block resurrection pass cannot mask the
miss). perry-runtime suite: 1322/0 single-threaded.

Claude-Session: https://claude.ai/code/session_01M44HoYS3CAYZyagm3ZzYDG
@proggeramlug
proggeramlug merged commit 0d79486 into PerryTS:main Jul 17, 2026
24 of 25 checks passed
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.

1 participant