Skip to content

fix(gc): #6206 evacuation stale references — shadow frames for closures/methods, helper operand rooting, old-page defrag off pending rewrite-contract fix#6219

Merged
proggeramlug merged 2 commits into
mainfrom
fix/gc-evac-stale-6206
Jul 10, 2026

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #6206 (GC evacuation leaves a stale reference to a moved array element). The issue turned out to be three independent defects, all confirmed against the issue's reproducer (~6/6 crash/corrupt under PERRY_GC_FORCE_EVACUATE=1 before; 8/8 + 4/4 + 2/2 clean across all policy modes after, re-validated post-rebase onto main incl. #6208/#6213/#6214/#6216):

1. Frameless closures/methods have no shadow-stack frame (#6081, the main blast radius)

Production copying/evacuating minors use exact roots only (ConservativeStackScanMode::Auto resolves to SkipDisabled, gc/roots.rs), so liveness rests entirely on the shadow stack — but shadow frames were only emitted for top-level functions and module init. compile_closure, compile_method, and compile_static_method passed an empty shadow_slot_map to codegen, so every pointer-typed param/local of every closure, arrow, method, and constructor body was invisible to an evacuating minor. A GC fired mid-body swept values reachable only from those frames; the referrer then read freed-and-reused memory. PERRY_GEN_GC_EVACUATE=0 masked it because the non-moving fallback minor runs the conservative native-stack scan.

Fix: emit the same shadow-frame machinery the top-level function path gets (collect_pointer_typed_localsenable_shadow_framejs_shadow_slot_bind for params → slot updates at Let sites) in all three compile paths. Methods additionally root the receiver (this) in one extra frame slot. Frames are skipped entirely when a body has no pointer-typed locals (enable_shadow_frame no-ops at slot_count == 0), same as before.

2. Runtime helpers hold raw operands across allocating calls

  • js_array_map / js_array_filter: receiver and result were rooted (RootedIterArray), but the callback closure param was not — an evacuating minor mid-loop swept/moved it, and the next dispatch called freed memory ("object is not a function"). Now rooted via the existing RuntimeHandleScope.
  • js_map_set / map_set_string_key_value: ensure_capacity can fire a moving minor via gc_note_external_side_alloc (its "conservative + non-moving" comment predates evacuation). The key/value operands — including a freshly built, not-yet-inserted object reachable via nothing else — are now rooted across the grow and re-derived after (including the *const StringHeader key).

3. Old-page defrag evacuation leaves stale references → disabled pending a rewrite-contract fix

With 1+2 fixed, the reproducer still corrupted at round ~19/32 — always at the first old-page defrag evacuation (recently activated by the #6193 releasable-block metric). Same-binary A/B: defrag on = 6/6 CORRUPT, defrag off = 6/6 clean.

Extensive instrumentation (leaked forwarding stubs + brute-force full-payload scans that deliberately bypass the per-object layout filter) verified that every heap-payload slot is correctly rewritten after defrag — arrays (in-length), object fields, Map entry buffers, overflow fields, and both registered root slots (the THREAD_GLOBAL_THIS raw-pointer TLS cache and GLOBAL_THIS_PTR). The consumer-visible failure (globalThis.Array reads undefined while the object and its 124 overflow slots verify intact) means the surviving stale reference lives on a non-heap path — an address-keyed cache/IC or side table the defrag rewrite doesn't reach. That needs its own root-cause; until then old-page defrag selection returns empty (nursery evacuation and tenured promotion — the reclaim-critical moving paths — are unaffected). PERRY_GC_OLD_DEFRAG=1 re-enables it for debugging/bisection.

Diagnostics kept (per the issue's "suggest adding as gated env vars")

  • The PERRY_GC_VERIFY_EVACUATION old→young edge verifier panic now prints per-GC-type histograms of missing edges plus parent/child type, generation, and mark state of the first miss.
  • PERRY_GC_VERIFY_RS_NONFATAL=1 demotes that verifier to a warning — it runs at AtomicFinalize after mark consumed the dirty logs, so its "missing" edges can be a measurement artifact (confirmed: coverage verifies complete at every restore point), and demoting it lets the later stale-forwarded-refs verifier in the same cycle be reached.

What was ruled out (matching + extending the issue's list)

  • Remembered-set completeness: a post-restore coverage check showed zero missing old→young edges after every minor (both the cycle path's triple restore and the copying fast path) — the entry-time "missing edges" the verifier reports are an after-mark measurement artifact.
  • js_array_map/js_array_filter iteration rooting, note_array_slot_layout_only barriers, keys-array element staleness, Map entry buffer rewriting, LAYOUT_SLOT_MASKS transfer on move — all verified correct under instrumentation.

Validation

  • Reproducer (issue's evac_stale_repro.ts), post-rebase onto main@6309eb7d8: PERRY_GC_FORCE_EVACUATE=1 ×8 clean, default policy ×4 clean, PERRY_GEN_GC_EVACUATE=0 ×2 clean (was ~6/6 crash rc=138/139 or CORRUPT exit).
  • Gap suite: zero delta vs origin/main — the gate lists 9 untriaged failures (proxy_reflect, 3662_function_hasinstance, 5591_method_string_coercion, asynchooks_3089+, disposablestack_2875, events_import_4995, http_overloads_3226plus, webdata_2612+, zlib_4917_level), but each fails byte-identically on current main (verified per-test A/B on the same box) — pre-existing from the 2026-07-09 merge wave, needs a separate triage/re-baseline.
  • cargo test --release -p perry-runtime: 1222 passed / 0 failed (three old-page-defrag unit tests now open the off-gate via a test-only RAII hook, OldDefragTestEnable).

Follow-up

Old-page defrag needs a dedicated root-cause of the non-heap stale-reference path before re-enabling (tracked in the #6206 thread).

Summary by CodeRabbit

  • Bug Fixes
    • Improved garbage-collection safety for closures, arrow bodies, instance/static methods, array callbacks, and map insertions—especially when memory movement can occur during capacity growth.
    • Reduced stale-reference risk and related runtime issues by correctly rooting callbacks/inputs across iterations and growth.
  • Diagnostics
    • Enhanced old→young reference verification diagnostics with richer missing-edge metadata and per-type histograms.
    • Added optional non-fatal reporting for verification failures via environment setting.
  • Tests
    • Improved test setup for old-page defragmentation and expanded verifier test initialization defaults.

…es/methods, helper operand rooting, old-page defrag off pending rewrite-contract fix

Three independent defects behind the "stale reference to a moved element"
corruption (wild-pointer crash / silently corrupt cached value under the
moving evacuation policy; masked by PERRY_GEN_GC_EVACUATE=0):

1. Frameless closures/methods (#6081, the main blast radius). Production
   copying/evacuating minors use exact roots only (conservative stack scan
   resolves to SkipDisabled), but shadow frames were emitted only for
   top-level functions and module init — compile_closure, compile_method,
   and compile_static_method passed an empty shadow_slot_map. Every
   pointer-typed param/local of every closure, arrow, method, and
   constructor body was invisible to an evacuating minor; a GC fired
   mid-body swept values reachable only from those frames. Now all three
   paths emit the same frame machinery the function path gets
   (collect_pointer_typed_locals → enable_shadow_frame → per-param
   js_shadow_slot_bind → slot updates at Let sites); methods root the
   receiver in one extra slot. Bodies with no pointer-typed locals still
   get no frame (closures/methods with an extra `this` slot excepted).

2. Runtime helpers holding raw operands across allocating calls:
   - js_array_map / js_array_filter rooted the receiver and result but
     not the callback closure param — an evacuating minor mid-loop
     swept/moved it and the next dispatch called freed memory.
   - js_map_set / map_set_string_key_value: ensure_capacity can fire a
     MOVING minor via gc_note_external_side_alloc (its "conservative +
     non-moving" comment predates evacuation); key/value — including a
     freshly built, not-yet-inserted object reachable via nothing else —
     are now rooted across the grow and re-derived after.

3. Old-page defrag evacuation leaves a stale reference on a non-heap
   path. With 1+2 fixed the reproducer still corrupted at the first
   old-page defrag cycle (same-binary A/B: defrag on = 6/6 CORRUPT,
   off = 6/6 clean). Instrumented scans (leaked forwarding stubs +
   brute-force payload walks bypassing the per-object layout filter)
   verified every heap-payload slot rewrites correctly — arrays
   (in-length), object fields, Map entry buffers, overflow fields, and
   both globalThis root slots — so the surviving stale reference lives
   on an address-keyed cache/IC/side-table path the defrag rewrite does
   not reach. Old-page defrag selection now returns empty pending that
   root-cause; nursery evacuation and tenured promotion are unaffected.
   PERRY_GC_OLD_DEFRAG=1 re-enables it for debugging/bisection.

Diagnostics kept (per the issue's ask): the PERRY_GC_VERIFY_EVACUATION
old→young edge verifier panic now prints per-GC-type histograms of the
missing edges plus parent/child type/generation/mark state of the first
miss, and PERRY_GC_VERIFY_RS_NONFATAL=1 demotes that verifier (which
runs after mark consumed the dirty logs, so its misses can be a
measurement artifact — coverage verifies complete at every restore
point) to a warning so later same-cycle verifiers stay reachable.

Validation: issue reproducer 8/8 clean under PERRY_GC_FORCE_EVACUATE=1,
4/4 clean on the default policy, 2/2 clean under PERRY_GEN_GC_EVACUATE=0
(previously ~6/6 crash rc=138/139 or CORRUPT exit).

Fixes #6206. Refs #6081.
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 827e8546-303b-43ad-87b1-d00a34d0d9ae

📥 Commits

Reviewing files that changed from the base of the PR and between a0f8dde and 0378c6e.

📒 Files selected for processing (1)
  • crates/perry-codegen/src/codegen/method.rs

📝 Walkthrough

Walkthrough

The PR adds shadow-stack support to generated closures and methods, roots pointers across moving-GC operations, expands old-to-young edge diagnostics with optional non-fatal reporting, and adds test-only control for old-page defragmentation.

Changes

GC safety and diagnostics

Layer / File(s) Summary
Shadow-stack code generation
crates/perry-codegen/src/codegen/closure.rs, crates/perry-codegen/src/codegen/method.rs
Closures and instance/static methods compute shadow slots, enable shadow frames, bind pointer parameters, and pass clear-point metadata into FnCtx.
Moving-GC pointer rooting
crates/perry-runtime/src/array/iter_methods.rs, crates/perry-runtime/src/map.rs
Array callbacks remain rooted during iteration, while Map keys and values are re-derived after capacity growth.
Old-to-young verifier diagnostics
crates/perry-runtime/src/gc/telemetry.rs, crates/perry-runtime/src/gc/verify.rs, crates/perry-runtime/src/gc/tests/barrier.rs
Missing-edge records include object metadata and histograms; verifier failures can warn once when non-fatal mode is configured.
Old-page defragmentation test gate
crates/perry-runtime/src/gc/oldgen.rs, crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs, crates/perry-runtime/src/gc/tests/cycle_state.rs, crates/perry-runtime/src/gc/tests/oldgen.rs
Defragmentation uses centralized configuration and a thread-local RAII test override used by affected tests.

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

Possibly related issues

Possibly related PRs

  • PerryTS/perry#5981 — Modifies the same array iteration callback paths for GC-safe execution.

Suggested labels: run-extended-tests

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the GC evacuation fix and the main implementation areas.
Description check ✅ Passed The description covers the fix summary, issue reference, validation, and main changes, even though it uses different headings than the template.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/gc-evac-stale-6206

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-codegen/src/codegen/method.rs`:
- Around line 1184-1199: Reserve one additional shadow-frame slot for static
this in the static method compilation path: size the frame using m.len() + 1,
and bind this_slot with js_shadow_slot_bind consistently with compile_method.
Update the related shadow-slot clear-point handling as needed so the resolved
receiver from js_static_this_resolve remains tracked as an exact root.
🪄 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: 7a2b9a7d-6470-4000-96d1-1b43ec0855f8

📥 Commits

Reviewing files that changed from the base of the PR and between 6309eb7 and a0f8dde.

📒 Files selected for processing (11)
  • crates/perry-codegen/src/codegen/closure.rs
  • crates/perry-codegen/src/codegen/method.rs
  • crates/perry-runtime/src/array/iter_methods.rs
  • crates/perry-runtime/src/gc/oldgen.rs
  • crates/perry-runtime/src/gc/telemetry.rs
  • crates/perry-runtime/src/gc/tests/barrier.rs
  • crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs
  • crates/perry-runtime/src/gc/tests/cycle_state.rs
  • crates/perry-runtime/src/gc/tests/oldgen.rs
  • crates/perry-runtime/src/gc/verify.rs
  • crates/perry-runtime/src/map.rs

Comment thread crates/perry-codegen/src/codegen/method.rs
CodeRabbit review on #6219: static `this` is usually the non-pointer
INT32 class-ref, but js_static_this_resolve returns a REAL heap receiver
for `C.m.call(x)` / `.apply(x)` / inherited `D.m()` dynamic dispatch —
and that object can be reachable only from the prologue's this_slot.
Mirror compile_method: one extra frame slot + js_shadow_slot_bind.
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 evacuation leaves a stale reference to a moved array element (wild-pointer crash / corrupt cached value)

1 participant