fix(gc): #6206 evacuation stale references — shadow frames for closures/methods, helper operand rooting, old-page defrag off pending rewrite-contract fix#6219
Conversation
…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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughThe 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. ChangesGC safety and diagnostics
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related issues
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (11)
crates/perry-codegen/src/codegen/closure.rscrates/perry-codegen/src/codegen/method.rscrates/perry-runtime/src/array/iter_methods.rscrates/perry-runtime/src/gc/oldgen.rscrates/perry-runtime/src/gc/telemetry.rscrates/perry-runtime/src/gc/tests/barrier.rscrates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rscrates/perry-runtime/src/gc/tests/cycle_state.rscrates/perry-runtime/src/gc/tests/oldgen.rscrates/perry-runtime/src/gc/verify.rscrates/perry-runtime/src/map.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.
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/6crash/corrupt underPERRY_GC_FORCE_EVACUATE=1before; 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::Autoresolves toSkipDisabled,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, andcompile_static_methodpassed an emptyshadow_slot_mapto 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=0masked 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_locals→enable_shadow_frame→js_shadow_slot_bindfor 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_frameno-ops atslot_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 existingRuntimeHandleScope.js_map_set/map_set_string_key_value:ensure_capacitycan fire a moving minor viagc_note_external_side_alloc(its "conservative + non-moving" comment predates evacuation). Thekey/valueoperands — 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 StringHeaderkey).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_THISraw-pointer TLS cache andGLOBAL_THIS_PTR). The consumer-visible failure (globalThis.Arrayreadsundefinedwhile 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=1re-enables it for debugging/bisection.Diagnostics kept (per the issue's "suggest adding as gated env vars")
PERRY_GC_VERIFY_EVACUATIONold→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=1demotes 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)
js_array_map/js_array_filteriteration rooting,note_array_slot_layout_onlybarriers, keys-array element staleness, Map entry buffer rewriting,LAYOUT_SLOT_MASKStransfer on move — all verified correct under instrumentation.Validation
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).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 currentmain(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