Skip to content

fix(gc): record layout + write barrier on raw pointer publishes into traced payload slots - #7179

Merged
proggeramlug merged 9 commits into
mainfrom
fix/7154-dynamic-field-young-edge
Aug 1, 2026
Merged

fix(gc): record layout + write barrier on raw pointer publishes into traced payload slots#7179
proggeramlug merged 9 commits into
mainfrom
fix/7154-dynamic-field-young-edge

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Two raw pointer stores made the default evacuating minor GC lose live objects. Both break the same invariant, and #7154's diagnostic signature (a NaN-boxed pointer at a traced slot, target header garbage, PERRY_GC_MOVING_LOOP_POLLS=0 makes it go away) is what each one produces.

Perry allocates every heap object in the POINTER_FREE layout state and only leaves that state when a store records a pointer through layout_note_slot. heap_payload_slot_selection short-circuits on POINTER_FREE and skips the entire payload without consulting any mask — so a raw *slot = bits into a traced slot is not merely imprecise, it makes the child invisible. The evacuating young-generation minor (default-on since #7019) then neither keeps the child alive nor rewrites the slot when the child moves, and the next read returns a pointer into reclaimed nursery memory: TypeError: value is not a function at a call site unrelated to the store, or a SIGSEGV inside the collector on a later cycle. The same raw store also skips runtime_write_barrier_slot, so an old→young edge never reaches the remembered set.

The invariant

A store that publishes a pointer-bearing value into a slot the collector traces — object field, array element, closure capture — must go through the barriered store helper (js_object_set_field / js_closure_set_capture_* / runtime_store_jsvalue_slot), which records the layout bit and dirties the remembered-set page. A raw slot write is sound only for a value that is provably not pointer-bearing, or for an object that is not yet published.

Both fixed sites now say this in a comment at the store.

What was fixed

1. bind_reserved_this_slot (symbol/properties.rs) — shared by js_object_set_method_by_name (#809: the ordered object-literal lowering used when a ...spread precedes a this-reading method) and js_object_set_symbol_method. It patched the closure's reserved this capture slot with a raw *slot = f64, so the closure stayed POINTER_FREE and the bound receiver was never traced. Now routes through js_closure_set_capture_f64.

2. js_weakmap_set's overwrite-existing-key path (weakref.rs) — wrote the new value into the entry's field 1, byte offset +40 (the exact offset #7154's diagnostic scan reports) with the unbarriered write_object_field_bits_raw. The insert path (weak_entry_new) already used js_object_set_field; the overwrite now matches. For a tenured entry this was a genuine missing remembered-set insert.

Two hardening changes in the same family:

3. js_closure_alloc undefined-initializes its capture slots. They were raw recycled arena bytes — invisible while the layout says POINTER_FREE, but decoded as references by anything that reads payload words (the conservative scan, PERRY_GC_FROMSPACE_SCAN, a later whole-range layout rebuild). Mirrors #7138's HOLE-initialisation of unused array capacity and what js_object_alloc already does for object fields.

4. js_object_set_field_by_name's three dynamic-key append paths bump field_count before publishing the value. gc_field_slot_range bounds the collector's view of the payload by field_count, so a slot the count does not yet cover is invisible to both tracing and evacuation rewriting; the old order left mirror_class_object_static_write sitting inside that window. Latent today (nothing reachable from that window allocates from the arena) — this makes the ordering correct by construction rather than by audit. Every physical slot is undefined-initialized at allocation, so widening the count first can only expose non-pointer sentinels.

Evidence

Reproducer: zod 4.4.3 compiled natively (perry.compilePackages), 400 schemas × 20 rounds of safeParse. Measured on the pre-#7161 main, where the evacuating minor was still default-on: it exits non-zero in the default configuration and is clean under PERRY_GC_MOVING_LOOP_POLLS=0 — the #7154 fingerprint. (After #7161 the shipped default no longer moves, so the crash now needs PERRY_GC_MOVING_LOOP_POLLS=1; the fix and the counts below are unchanged either way.)

PERRY_GC_FROMSPACE_SCAN=1, per cycle:

before after
dangling type=4 (closure capture, +16) edges 294 0
distinct offending owners 298

The traced mechanism, established with temporary probes (all reverted):

  1. Whole-heap from-space scan named the offenders: type=4 closures freshly copied into Survivor1, capture slot 0, pointing at un-evacuated NurseryEden objects, ever_dirty=false.
  2. A probe on the offenders' headers reported _reserved = 0x4008GC_LAYOUT_POINTER_FREE, with the collector's own gc_child_slots enumerator emitting zero child slots and the offending slot falling inside the skipped PointerFreeRange.
  3. capture_count = 0x80000001CAPTURES_THIS_FLAG | 1: the dangling slot is the reserved this slot.
  4. A #[track_caller] trap on set_layout_state and on layout_transfer showed the Eden original was already POINTER_FREE with a live pointer capture and no per-object mask — i.e. the capture was never recorded, not lost on the move. That points at the one store path that writes that slot without the helper: bind_reserved_this_slot.

Tests

crates/perry-runtime/src/gc/tests/copying/pointer_publish_7154.rs, three tests. Each first interrogates the collector's own child-slot enumerator (deterministic — immune to GC timing and conservative-stack pinning), then asserts end-to-end survival across a collection proven to have copied (copied_objects > 0, kept true by a rooted young canary so the liveness gate cannot be satisfied by the very edge under test).

Sabotage, one fix reverted at a time:

test fix present raw store restored
test_bound_this_capture_is_traced_after_method_bind_7154 pass FAIL — "the collector reported []"
test_weakmap_overwrite_value_is_traced_7154 pass FAIL — "the WeakMap value slot no longer names an object"
test_fresh_closure_capture_slots_are_initialized_7154 pass FAIL — slot 0 held recycled bytes

cargo test -p perry-runtime --lib -- --test-threads=1 (perry-dev): 1603 passed, 0 failed, 3 ignored. cargo fmt --all --check clean. scripts/check_file_size.sh clean.

GC x repsel matrix

New gap test test-files/test_gap_gc_pointer_publish.ts, registered in test-parity/gc_repsel_corpus.txt. It exercises all three fixed publish sites (spread-then-this-method object literal, computed Symbol.toPrimitive method, WeakMap overwrite of a tenured entry), and every subject is read after the churn that drives collection.

Re-run after rebasing onto the merged #7161, --arms all --pressure 8:

summary: PASS=17 UNVER=4 XFAIL=0 FAIL=0
byte-exact vs node 26.5.1: 21/21 cells

Every requires=move arm bites — evac_minor, force_evac, force_verify, loop_polls and all eight rep_* arms report moved-objects 1/1, copy-minor 1/1. 33 copying minors under PERRY_GC_MOVING_LOOP_POLLS=1.

The four UNVER cells (default, verify_evac, cons_scan_off, cons_scan_off_force) are #7161's doing, and are correct: with the evacuating minor off by default the file drives zero copying minors, and the bug it guards cannot manifest without a moving collector — a PASS there would be exactly the false confidence the harness exists to remove. This is not specific to this file: test_gap_repsel_gc_stress, the corpus's designated GC-live member, now reports the identical PASS=17 UNVER=4 shape. The corpus entry documents this and says what to do if the moving-loop knob is ever deleted.

Collector-side coverage does not depend on that knob. The three Rust tests call gc_collect_minor directly, which takes the copying path regardless of PERRY_GC_MOVING_LOOP_POLLS, and each asserts copied_objects > 0. Verified green on the flipped default: 1608 passed, 0 failed.

Red-then-green on the gap file, same binary, fixes reverted: exit 138 (SIGBUS); byte-exact vs Node with the fixes.

Review round (both 🟠 Majors addressed)

Both were property 3 of the three-property contract — the value the call observed — and both are fixed in 1c15d441b.

  • bind_reserved_this_slot held obj_f64 raw across the barriered capture store. I traced the reachability first: the store is not an arena allocator today (layout_note_slot touches only thread-local layout maps — a system-allocator insert, which does not feed GC_NEXT_MALLOC_TRIGGER; runtime_write_barrier_gc_slot ends in a remembered-set insert), so nothing was miscompiled. I took the fix anyway, because that is a claim about three functions nobody is obliged to preserve and String literal operand is not GC-rooted across an allocating call in the same expression (stale handle after evacuation) #7114 is what it costs when one goes stale. Both entry points now root receiver / key-or-symbol / closure in a RuntimeHandleScope and re-derive every raw pointer after the bind, including the returned receiver. bind_reserved_this_slot documents itself as a collection point.
  • Checked the sibling site. js_weakmap_set's overwrite arm is already correct for a structural reason: the barriered store is the last thing that arm does and it returns from map_handle, so neither entries_ptr nor entry is read afterwards. The enclosing loop's "this scan performs no allocation" comment was made narrower than it reads, so the invariant is now annotated with what a future editor must do if that arm ever falls through.
  • Test 1 read the closure through a raw local across an allocating call. Fixed, and widened past the suggested diff: the receiver and key were also raw locals in the same argument list, and Rust's left-to-right argument evaluation meant allocating the key could stale the already-read receiver. All three now live in shadow slots. Using three slots also let the receiver's only root become the capture edge under test (slots 1 and 2 are cleared before the collection), which the one-slot version could not express. Sabotage re-verified after the restructuring — still red, still on the first deterministic assertion, still for the right reason.

Not fixed here

This is necessary but not sufficient for the zod workload: with the closure-capture population at zero, that binary still fails whenever the moving minor is enabled (pre-#7161 default; PERRY_GC_MOVING_LOOP_POLLS=1 after it) and is still clean when it is not, so at least one more offender remains. The residue the scan still reports is dominated by type=1 array owners whose own header carries GC_FLAG_FORWARDED (stale relocated copies — probably scan false positives, since scan_object skips from-space owners but not forwarded ones) plus the documented type=3 conservative-scan string false positives. Six genuine-looking SHAPE_SHARED keys-array slots in Survivor0 are the most interesting remainder. The #7161 stopgap should not be dropped on the strength of this PR alone.

scripts/addr_class_inventory.py fails on this branch, in child_process/value_util.rs and fs/dirent.rs — neither is touched here (git diff origin/main --name-only confirms); pre-existing on main.

Fixes part of #7154. Refs #7161, #7138.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR replaces raw pointer stores with barriered updates, orders dynamic field publication before value storage, initializes closure captures to TAG_UNDEFINED, and adds copying-GC regression tests for closure captures and WeakMap overwrites.

Changes

GC pointer publication

Layer / File(s) Summary
Closure capture and method publication
crates/perry-runtime/src/closure/alloc.rs, crates/perry-runtime/src/symbol/properties.rs
New capture slots initialize to TAG_UNDEFINED. Method binding roots values across collection points and uses barriered capture storage.
WeakMap and dynamic field publication
crates/perry-runtime/src/weakref.rs, crates/perry-runtime/src/object/field_set_by_name.rs
WeakMap overwrites use js_object_set_field. Dynamic field paths update field_count before storing values.
Copying-GC regression coverage
crates/perry-runtime/src/gc/tests/*, test-files/test_gap_gc_pointer_publish.ts, test-parity/gc_repsel_corpus.txt, changelog.d/7179-gc-pointer-publish-barrier.md
Tests validate slot enumeration, object survival, pointer rewriting, churn behavior, and undefined capture initialization. The corpus and changelog record the regression coverage and fixes.

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

Possibly related issues

Possibly related PRs

  • PerryTS/perry#6849 — Uses related GC child-slot tracing and object-field publication infrastructure.
  • PerryTS/perry#6941 — Addresses rooting live receivers and values across GC-capable operations.
  • PerryTS/perry#6975 — Addresses related closure capture GC pointer-rooting paths.

Suggested labels: ready

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly and concisely identifies the primary GC layout-recording and write-barrier fix.
Description check ✅ Passed The description thoroughly covers the changes, related issues, test results, remaining limitations, and verification evidence.
✨ 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/7154-dynamic-field-young-edge

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: 2

🤖 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/copying/pointer_publish_7154.rs`:
- Around line 58-82: Root the closure before calling
js_object_set_method_by_name, which may trigger copying GC, by storing its
address in the existing shadow slot and reloading it through the test's live
helper afterward. Use the reloaded closure for capture_slot and
enumerated_child_slots, preserving the existing slot reuse in the later survival
phase.

In `@crates/perry-runtime/src/symbol/properties.rs`:
- Line 664: Update the field-write sequence in bind_reserved_this_slot so
obj_f64 remains rooted through js_closure_set_capture_f64, layout_note_slot, and
runtime_write_barrier_gc_slot. Before js_object_set_field_by_name dereferences
it, obtain or refresh the object pointer from the live handle scope rather than
using the potentially stale raw pointer.
🪄 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: dd419416-be06-415b-a156-7dff9312d5a1

📥 Commits

Reviewing files that changed from the base of the PR and between b68fd83 and 923ceb1.

📒 Files selected for processing (7)
  • changelog.d/7179-gc-pointer-publish-barrier.md
  • crates/perry-runtime/src/closure/alloc.rs
  • crates/perry-runtime/src/gc/tests/copying.rs
  • crates/perry-runtime/src/gc/tests/copying/pointer_publish_7154.rs
  • crates/perry-runtime/src/object/field_set_by_name.rs
  • crates/perry-runtime/src/symbol/properties.rs
  • crates/perry-runtime/src/weakref.rs

Comment thread crates/perry-runtime/src/gc/tests/copying/pointer_publish_7154.rs Outdated
Comment thread crates/perry-runtime/src/symbol/properties.rs
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merge order: #7180 must land first. lint (cargo fmt --all -- --check) is currently red on main itself — #7161 landed with a rustfmt violation in the test it added — so it is red on every PR branched from it, including this one. The only Diff in on this branch is that inherited file; nothing this PR touches is unformatted:

$ cargo fmt --all -- --check | grep '^Diff in'
Diff in crates/perry-runtime/src/gc/tests/triggers.rs:438

#7180 is the one-line fix (formatting only, no behaviour change). Once it lands, re-running this PR's checks against the updated base is enough — there is no conflict, this PR does not touch triggers.rs.


Review round is addressed (1c15d441b); both 🟠 Major threads have replies with the reasoning, the sabotage re-verification, and the sibling-site audit. Re-verified after rebasing onto the merged #7161:

check result
cargo test -p perry-runtime --lib --test-threads=1 1608 passed, 0 failed, 3 ignored
GC×repsel matrix, --arms all --pressure 8 PASS=17 UNVER=4 FAIL=0, byte-exact 21/21
requires=move arm liveness moved-objects 1/1, copy-minor 1/1 on all of them
gap test under PERRY_GC_MOVING_LOOP_POLLS=1 (compile and run) byte-exact, 33 copying minors
gap test, fixes reverted exit 138 (SIGBUS)
zod closure-capture dangling edges/cycle 0

The 4 UNVER cells are #7161's doing and are correct — with the evacuating minor off by default the file drives zero copying minors, and a PASS there would assert nothing about a moving collector. Worth knowing that this is repo-wide, not specific to this file: test_gap_repsel_gc_stress, the corpus's designated GC-live member, now reports the identical PASS=17 UNVER=4 shape. After #7161, loop_polls and the other requires=move arms are the only place the evacuating minor is exercised end-to-end at all.

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