Skip to content

fix(gc): minor sweep reclaimed array-growth forwarding stubs still referenced from old-gen parents#6471

Merged
proggeramlug merged 1 commit into
PerryTS:mainfrom
proggeramlug:fix/minor-sweep-growth-stub-retention
Jul 16, 2026
Merged

fix(gc): minor sweep reclaimed array-growth forwarding stubs still referenced from old-gen parents#6471
proggeramlug merged 1 commit into
PerryTS:mainfrom
proggeramlug:fix/minor-sweep-growth-stub-retention

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Array growth installs a PERMANENT forwarding stub at the pre-growth address (#6228): stale pre-growth pointers keep resolving for reads, and references to stubs are never rewritten. But the minor sweep reclaimed any FORWARDED stub that was neither marked this cycle nor inside the recent-allocation block window, assuming unmarked stubs are "stale array-growth remnants".

That assumption is unsound in a minor: old-gen parents are black leaves — their slots are visited only via remembered-set dirty pages — so a stale pre-growth pointer held in an old parent whose page is not dirty (a long-lived Map's entries buffer that hasn't been written since) never marks the stub. "Unmarked" does NOT imply "unreferenced". Once the stub's block aged out of the recent window, the sweep freed it; reads through the still-live stale pointer then returned reused-memory garbage.

Observed in a large esbuild-bundled TUI app: a render cell-cache Map value (a grown cells array reached via its pre-growth pointer) read .length back as a heap pointer after a few minors — pointer-shaped, varying per run, elements at higher offsets still intact — blowing up every subsequent frame, so typed input never appeared on screen. The failure needs an old parent + non-dirty page + window expiry to line up, which is why smaller reproductions kept passing.

Fix

Thread retain_all_forwarded_stubs from step_sweep into the arena sweep:

  • Minor sweeps retain EVERY forwarding stub — a minor cannot prove one unreferenced.
  • Full traces visit every live parent, so their mark-based stub reclaim stays as-is and bounds the accumulation.
  • The synchronous sweep wrappers use do_age_bump (true exactly for minor sweeps) as the same signal.

Diagnostics

Adds two env-gated, non-fatal reporters (PERRY_GC_VERIFY_MARK) used to isolate this: a marked-heap mark-invariant reporter on the copied-minor path, and a pre-sweep old-parent → unmarked-young-child reporter at the budgeted minor's AtomicFinalize boundary.

Test

minor_sweep_retains_window_expired_growth_stub drives IncrementalSweepState exactly as a minor builds it, with a genuinely unmarked, window-expired growth stub (small-allocation churn to advance the general window past the stub's block; the test-build full-conservative-scan default and large-allocation routing both mask the scenario otherwise — validated failing-then-passing: pre-fix the sweep strips FORWARDED and reclaims the stub; post-fix it is retained, retained_forwarded_stub_objects reports it, and reads through the stale pointer still resolve).

Full perry-runtime suite: 1315 passed / 0 failed (single-threaded).

https://claude.ai/code/session_01XRHKpxgDnVB3GJdsV9ud7g

Summary by CodeRabbit

  • Bug Fixes

    • Improved minor garbage-collection handling to retain forwarding information when it may still be needed, preserving access to expanded objects.
    • Added safeguards to reduce the risk of invalid references during minor collection and sweeping.
  • Diagnostics

    • Added optional, non-fatal heap verification when PERRY_GC_VERIFY_MARK is enabled.
    • Diagnostic reports identify potential marking and young-generation reference issues without interrupting collection.

…ferenced from old-gen parents

Array growth installs a PERMANENT forwarding stub at the pre-growth
address (PerryTS#6228): stale pre-growth pointers keep resolving for reads, and
references to stubs are never rewritten. But the minor sweep reclaimed
any FORWARDED stub that was neither marked this cycle nor inside the
recent-allocation block window, on the assumption that unmarked stubs
are "stale array-growth remnants".

That assumption is unsound in a minor: old-gen parents are black
leaves — their slots are visited only via remembered-set dirty pages —
so a stale pre-growth pointer held in an old parent whose page is not
dirty (a long-lived Map's entries buffer that hasn't been written
since) never marks the stub. "Unmarked" does NOT imply "unreferenced".
Once the stub's block aged out of the recent window, the sweep freed
it; reads through the still-live stale pointer then returned
reused-memory garbage.

Observed in a large esbuild-bundled TUI app: a render cell-cache Map
value (a grown cells array reached via its pre-growth pointer) read
`.length` back as a heap pointer after a few minors, blowing up every
subsequent frame — typed input never appeared on screen. The failure
needed an old parent, a non-dirty page, and window expiry to line up,
which is why smaller reproductions kept passing.

Fix: thread `retain_all_forwarded_stubs` from `step_sweep` into the
arena sweep. Minor sweeps retain EVERY forwarding stub — they cannot
prove one unreferenced. Full traces visit every live parent, so their
mark-based stub reclaim stays as-is and bounds the accumulation. The
synchronous sweep wrappers use `do_age_bump` (true exactly for minor
sweeps) as the same signal.

Also adds two env-gated, non-fatal GC diagnostics (`PERRY_GC_VERIFY_MARK`)
used to isolate this: a marked-heap mark-invariant reporter on the
copied-minor path, and a pre-sweep old-parent → unmarked-young-child
reporter at the budgeted minor's AtomicFinalize boundary.

Regression test drives `IncrementalSweepState` exactly as a minor
builds it, with a genuinely unmarked, window-expired growth stub
(small-allocation churn to advance the general window; the test-build
conservative-scan default masks the scenario otherwise): pre-fix the
sweep strips FORWARDED and reclaims the stub; post-fix it is retained
and reads through the stale pointer still resolve.

Claude-Session: https://claude.ai/code/session_01XRHKpxgDnVB3GJdsV9ud7g
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Minor GC sweep configuration now retains forwarding stubs through arena cleanup. Optional verification probes report marked-to-unmarked references at minor collection boundaries, with regression coverage for stale growth pointers and updated sweep-state construction.

Changes

Minor GC retention and verification

Layer / File(s) Summary
Mark verification diagnostics
crates/perry-runtime/src/gc/verify.rs, crates/perry-runtime/src/gc/cycle.rs, crates/perry-runtime/src/gc/copying.rs
Adds non-fatal heap verification reports and invokes them from the minor prelude and copied-minor fast path when PERRY_GC_VERIFY_MARK is set.
Forwarding stub retention wiring
crates/perry-runtime/src/gc/cycle.rs, crates/perry-runtime/src/gc/oldgen.rs
Propagates a minor-sweep retention flag through incremental and arena sweep state, retaining forwarded stubs when the flag is set or the stub is marked.
Retention regression and sweep call sites
crates/perry-runtime/src/gc/tests/debt_pacer.rs, crates/perry-runtime/src/gc/tests/incremental_sweep_reclaim.rs
Tests retention of an unmarked, window-expired growth stub and updates sweep-state constructor arguments.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GcCycleState
  participant IncrementalSweepState
  participant ArenaSweepObjectsState
  participant ForwardingStub
  GcCycleState->>IncrementalSweepState: configure minor sweep with stub retention
  IncrementalSweepState->>ArenaSweepObjectsState: pass retention flag
  ArenaSweepObjectsState->>ForwardingStub: process forwarded object
  ArenaSweepObjectsState-->>ForwardingStub: retain stub when flag is set
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is specific and matches the main change: minor sweeps now retain array-growth forwarding stubs referenced by old-gen parents.
Description check ✅ Passed The description covers the fix, diagnostics, and test evidence, with only minor template gaps like the related-issue line and checklist formatting.
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

🧹 Nitpick comments (1)
crates/perry-runtime/src/gc/verify.rs (1)

791-794: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Sort the histogram entries for deterministic diagnostic output.

HashMap iteration is non-deterministic. Sorting the entries by parent and child object types before logging will ensure consistent output across different test runs or executions.

♻️ Proposed refactor
         let mut hist_str = String::new();
-        for ((pt, ct), n) in &hist {
-            hist_str.push_str(&format!(" {}({})->{}({})={}", tn(*pt), pt, tn(*ct), ct, n));
+        let mut entries: Vec<_> = hist.iter().collect();
+        entries.sort_unstable_by_key(|(&(pt, ct), _)| (pt, ct));
+        for (&(pt, ct), n) in entries {
+            hist_str.push_str(&format!(" {}({})->{}({})={}", tn(pt), pt, tn(ct), ct, n));
         }
🤖 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/verify.rs` around lines 791 - 794, Sort the
entries from hist by parent and child object types before building hist_str in
the histogram formatting loop. Preserve the existing tn formatting and counts,
but iterate over the sorted entries so diagnostic output is deterministic
despite hist being a HashMap.
🤖 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/verify.rs`:
- Around line 786-788: Update the minor-sweep old-parent verification around
old_arena_walk_objects and include MALLOC_STATE in the objects passed to
visit_parent. Ensure malloc-registry old-generation objects, including large
closures, are traversed so their child edges receive the same verification as
arena-backed objects.

---

Nitpick comments:
In `@crates/perry-runtime/src/gc/verify.rs`:
- Around line 791-794: Sort the entries from hist by parent and child object
types before building hist_str in the histogram formatting loop. Preserve the
existing tn formatting and counts, but iterate over the sorted entries so
diagnostic output is deterministic despite hist being a HashMap.
🪄 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: 8791663d-8b37-4c70-a08a-dbe70d7df187

📥 Commits

Reviewing files that changed from the base of the PR and between 949ac76 and 8a2fa5c.

📒 Files selected for processing (6)
  • crates/perry-runtime/src/gc/copying.rs
  • crates/perry-runtime/src/gc/cycle.rs
  • crates/perry-runtime/src/gc/oldgen.rs
  • crates/perry-runtime/src/gc/tests/debt_pacer.rs
  • crates/perry-runtime/src/gc/tests/incremental_sweep_reclaim.rs
  • crates/perry-runtime/src/gc/verify.rs

Comment on lines +786 to +788
crate::arena::old_arena_walk_objects(|hp| {
visit_parent(hp as *mut GcHeader);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Include MALLOC_STATE in the minor-sweep old-parent verification.

The diagnostic probe walks old_arena_walk_objects to verify that no surviving old-gen parents have unmarked sweep-eligible children. However, it currently omits MALLOC_STATE, meaning any old-gen objects allocated via the malloc registry (like large closures) are skipped and their edges are not verified.

🛠️ Proposed fix to verify old-gen malloc objects
     crate::arena::old_arena_walk_objects(|hp| {
         visit_parent(hp as *mut GcHeader);
     });
+    MALLOC_STATE.with(|s| {
+        let s = s.borrow();
+        for &header in s.objects.iter() {
+            visit_parent(header);
+        }
+    });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
crate::arena::old_arena_walk_objects(|hp| {
visit_parent(hp as *mut GcHeader);
});
crate::arena::old_arena_walk_objects(|hp| {
visit_parent(hp as *mut GcHeader);
});
MALLOC_STATE.with(|s| {
let s = s.borrow();
for &header in s.objects.iter() {
visit_parent(header);
}
});
🤖 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/verify.rs` around lines 786 - 788, Update the
minor-sweep old-parent verification around old_arena_walk_objects and include
MALLOC_STATE in the objects passed to visit_parent. Ensure malloc-registry
old-generation objects, including large closures, are traversed so their child
edges receive the same verification as arena-backed objects.

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