Skip to content

gate(gc): promote the parse-then-churn layout-state check to CI (#7647) - #7711

Merged
proggeramlug merged 3 commits into
mainfrom
gc/7647-parse-churn-gate
Aug 9, 2026
Merged

gate(gc): promote the parse-then-churn layout-state check to CI (#7647)#7711
proggeramlug merged 3 commits into
mainfrom
gc/7647-parse-churn-gate

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Closes #7647.

What this promotes

#7643 (layout_finish_deferred_boxed_object hazard tests) left a follow-up
note: PERRY_JSON_TAPE=0 + PERRY_GC_FROMSPACE_SCAN=1 over a parse-then-churn
workload is a known-good end-to-end detector for the whole layout-state
family (#7630/#7633/#7635/#7644) — with the JSON materialiser's finalize
sabotaged to always claim POINTER_FREE, it reports dangling=8000 owners=4000 and the binary SIGBUSes; clean, dangling=0, exit 0. Nothing ran
it in CI. #7643/#7644's unit tests are the right primary guard (workload-free,
so they can't be defeated by a lazy path or a GC that didn't happen to run),
but neither can catch a new materialiser path that forgets to finalize at
all, since a hand-built unit-test object never exercises a real call site.

This PR ships that gate:

  • scripts/fixtures/gc_parse_churn_layout_state.ts — 4,000 JSON records, 2
    pointer-bearing string fields each (matching test(gc): make the POINTER_FREE misdeclaration hazard detectable (#7635) #7643's own reproduction
    numbers), sized to be Auto-mode-lazy-eligible, parsed, churned, then
    read back and compared field-by-field.
  • scripts/gc_parse_churn_layout_gate.sh — compiles the fixture with
    PERRY_GC_MOVING_LOOP_POLLS=1 (a compile-time gate as well as a runtime
    one — the moving collector is opt-in per fix(gc): disable evacuating minor by default pending #7154 (use-after-free on dynamically-added fields) #7161's stopgap, and without it
    every collection falls back to a non-moving conservative-stack-scan cycle
    that never runs the from-space scan at all), runs it under
    PERRY_JSON_TAPE=0 PERRY_GC_FROMSPACE_SCAN_ABORT=1 PERRY_GC_DIAG=1, and
    hands stdout/stderr/exit-code to the checker.
  • scripts/gc_parse_churn_layout_check.py — the actual pass/fail logic,
    asserting three things before it can say PASS:
    1. correctness — exit 0, the fixture's own MISMATCHES 0, no scan
      offender line;
    2. livenesscopied_objects summed across every [gc-copy-minor] ran ...] line is nonzero (a copying minor actually relocated something —
      CLAUDE.md's "four ways a gate can be unable to fail", useEffect + setState panics with RefCell already borrowed on macOS ARM64 #4: a run with
      zero collections must not pass);
    3. eagerness — the from-space scan's own objects= census reached at
      least the fixture's record count. PERRY_JSON_TAPE=0 is supposed to
      force the direct (eager) parser for every call, but trusting the env
      var alone is exactly the kind of assumption gc: forcing POINTER_FREE on a pointer-bearing object strands nothing — our zeal/protect instruments do not discriminate the layout-state hazard #7635 showed worth
      checking: a lazily-parsed cohort leaves only a handful of tape/lazy-
      array objects live at scan time, nowhere near the record count. This
      is the "record count materialised before the churn, or an equivalent
      observable" Promote the tape=0 + from-space-scan parse-then-churn check to a CI gate for the layout-state family #7647 asks for.
  • python3 scripts/gc_parse_churn_layout_check.py --self-test proves the
    checker can say no on all three axes, in both directions (7 cases: a clean
    run passes; a real dangling reference, silent post-churn corruption, zero
    copying minors, the scan never running, and the lazy-tape/vacuity shape
    each fail with a distinct, specific message).
  • .github/workflows/gc-parse-churn-gate.yml — wires it up. on: pull_request / push: branches: [main] / workflow_dispatch, no continue-on-error, no
    || true, concurrency.cancel-in-progress scoped to pull_request only
    (main runs queue by commit SHA rather than cancelling each other —
    CI: gc-ratchet's main runs are cancelled while queued, so the gate has executed zero times in three merges #7205's lesson). A path-relevance filter (mirroring
    gc-moving-witnesses.yml) skips the compiler build on changes that can't
    reach this path.
  • Registered in scripts/gc_gate_wiring_check.py's GATES list (the repo's
    existing "is this gate main-line-reachable and able to fail" auditor), so
    a future edit that accidentally makes it tags-only or job-level
    continue-on-error fails lint instead of going unnoticed like
    gc-root-dominance did for weeks.

Not required-on-merge. Per CLAUDE.md's corollary to "four ways a gate can
be unable to fail" — a new gate has never been green, so promoting it
immediately blocks every open PR — this needs one observed green run on
main first. Adding gc-parse-churn-gate to branch protection's required
contexts is a maintainer action; this PR does not and cannot do it.

A real bug the fixture found along the way

Building the fixture, the "clean" arm was NOT clean: on a correct,
unsabotaged runtime, roughly 1 in 40 parsed-record strings reported a false
dangling hit, deterministic given fixed inputs, always type=3 (String)
owner, always at a fixed relative offset.

Root cause: arena_alloc_gc pads every allocation's total size up to 8-byte
alignment (gc_padded_total_size), so a string whose own natural size isn't
already a multiple of 8 gets 0–7 trailing bytes that are part of the
allocation (GcHeader.size, what PERRY_GC_FROMSPACE_SCAN trusts as the
payload's true extent) but were never requested by, or written by, the
caller. js_array_grow already has a fix for the equivalent hazard on array
growth — its [old_capacity, new_capacity) slack is explicitly
TAG_HOLE-filled, with a comment naming this exact class of bug — but
string_storage_alloc had no equivalent, and strings routinely land on
non-8-aligned lengths. Leftover bytes from whatever the arena block last
held there can, and measurably did, decode as a plausible NaN-boxed or bare
pointer. Harmless to every actual string API (.length, indexing, iteration
are all bounded by byte_len/capacity, never GcHeader.size) — confirmed
by MISMATCHES 0 and clean exits throughout, including deliberately
churn-free and churn-heavy variants used to isolate it — but a genuine
false-positive source for this exact instrument.

Fixed at the single choke point (string_storage_alloc /
string_storage_alloc_longlived in crates/perry-runtime/src/string/mod.rs):
read GcHeader.size back after allocation and zero the difference between
that and the requested payload size (≤7 bytes, negligible next to the
content copy it sits beside). The GcHeader cast this needs is the same
"fresh allocation, never a NaN-box payload" shape already grandfathered for
arena/allocators.rs; added a matching, narrowly-scoped
scripts/addr_class_allowlist.txt entry rather than routing through
addr_class::try_read_gc_header (which classifies untrusted NaN-box payload
magnitudes — not what's happening here, since raw is a pointer this same
function just got back from the allocator).

cargo test -p perry-runtime --lib (1945 passed, 0 failed) after the fix.

Sabotage verification

Reapplied #7635/#7643's exact mutation locally (both parse_object call
sites in crates/perry-runtime/src/json/parser.rs,
layout_finish_deferred_boxed_object(js_obj as usize, /* saw_pointer */ false)), rebuilt the runtime, and ran the gate script three times:

== running under PERRY_JSON_TAPE=0 + PERRY_GC_FROMSPACE_SCAN_ABORT=1 ==
...
  owner=0x2dfa67ac118 type=2 space=Survivor0 +32 nanbox -> 0x2dfa52f6ac8 (type=3 NurseryEden) DANGLING (target not evacuated) [...]
thread '<unnamed>' panicked at crates/perry-runtime/src/gc/fromspace_scan.rs:351:5:
gc from-space scan: 0 missing rewrite(s), 1 dangling reference(s) survived the rewrite pass
-- exit code: 134 --
FAIL: parse-then-churn layout-state gate
  - process exited 134, expected 0 (...)
  - stdout never printed 'PARSE_CHURN_LAYOUT_GATE_OK' -- the fixture did not run to completion...
  - stdout has no 'MISMATCHES <n>' line...
  - stderr contains a '[gc-fromspace-scan abort]' line reporting offenders (objects=4037)...

Reverted the sabotage, rebuilt, reran three more times: consistent
PASS: parse-then-churn layout-state gate is clean and its subject was live.
at exit 0, with [gc-copy-minor] ran copied_objects=... lines showing
nonzero relocation across 5+ collection cycles and the from-space scan's
objects= census running 17k+ (well above the 4,000-record floor) each
time.

Also verified the vacuous-configuration case is rejected at the checker level
(--self-test's "tape stayed lazy" case): a captured run whose scan census
never exceeds a handful of objects fails with an explicit "record cohort was
not eagerly materialised... #7635's original vacuity, recurring" message,
not a silent pass.

What this deliberately does not cover

One fixed-shape probe (4,000 records, 2 string fields), not a sweep over
every layout-state shape the codebase can produce — the end-to-end
complement to #7643/#7644's unit tests, not a replacement for them, and not
a substitute for gc-moving-witnesses (#7154 stale-root reproducers) or
gc-root-dominance (static root-store dominance).

Validation

proggeramlug pushed a commit that referenced this pull request Aug 9, 2026
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@proggeramlug, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 6 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8df1f952-78ea-48f4-998e-dae96af1eba9

📥 Commits

Reviewing files that changed from the base of the PR and between 5b75176 and 46f4c5d.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (10)
  • .github/workflows/gc-parse-churn-gate.yml
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/7711-parse-churn-layout-gate.md
  • crates/perry-runtime/src/string/mod.rs
  • scripts/addr_class_allowlist.txt
  • scripts/fixtures/gc_parse_churn_layout_state.ts
  • scripts/gc_gate_wiring_check.py
  • scripts/gc_parse_churn_layout_check.py
  • scripts/gc_parse_churn_layout_gate.sh
✨ 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 gc/7647-parse-churn-gate

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.

Ralph Küpper added 3 commits August 9, 2026 17:50
#7643 measured that PERRY_JSON_TAPE=0 + PERRY_GC_FROMSPACE_SCAN=1 over a
parse-then-churn workload is a known-good end-to-end detector for the
layout-state family (#7630/#7633/#7635/#7644), but nothing ran it in CI.
Adds the fixture, a driver script, and a Python checker whose verdict
requires correctness (dangling=0, byte-exact read-back after the churn),
liveness (a copying minor actually relocated objects), and eagerness (the
scan's own object census reached the record count, so the cohort was not
still on the lazy tape) before it can say PASS -- and a --self-test proving
the checker can say no on all three, in both directions.

Building the fixture surfaced a real, general false-positive source in
PERRY_GC_FROMSPACE_SCAN: string allocations were not zero-filling their
0-7 byte 8-byte-alignment pad, so leftover arena bytes there could
occasionally decode as a plausible pointer and report a false "dangling"
hit -- harmless to every string API (all bounded by byte_len/capacity, never
GcHeader.size) but not to a scan that trusts GcHeader.size as the payload's
true extent. Fixed at string_storage_alloc's single choke point, mirroring
the existing TAG_HOLE fix for array-growth slack.

Not wired into branch protection's required contexts here -- that is a
maintainer action for after this job's first green run on main.
@proggeramlug
proggeramlug force-pushed the gc/7647-parse-churn-gate branch from 2a4213b to 46f4c5d Compare August 9, 2026 15:50
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merging as v0.5.1412

Three things make this a gate rather than a script, and all three are the ones this repo keeps finding missing.

1. The live-subject assertion has three axes, all required to PASS:

  • correctness — exit 0, fixture's own MISMATCHES 0, no scan offender
  • livenesscopied_objects summed across [gc-copy-minor] ran … is nonzero, i.e. a copying minor actually relocated something
  • eagerness — the scan's objects= census reached the 4,000-record floor, proving the cohort was not still on the lazy tape

That third axis is the one that matters here and the reason #7647 existed. PERRY_JSON_TAPE=0 is a knob setting, not proof it was honored, and the whole #7635 family was vacuous precisely because a parse→churn→read probe materializes its records after the collections — testing an empty heap. A gate that asserted only "nothing threw" would have inherited that.

2. It can fail, demonstrated. Reapplying #7635/#7643's exact mutation (layout_finish_deferred_boxed_object(js_obj as usize, false) at both parse_object call sites) gives a consistent exit 134 across 3 runs, with the from-space scan reporting a real type=2 (Object) → type=3 (String) dangling reference and a precise diagnosis. Reverted: consistent PASS across 3 more, with 5+ live copying-minor cycles and objects=17k+ censuses. The checker's --self-test covers both directions, 7/7.

3. It compiles and runs under PERRY_GC_MOVING_LOOP_POLLS=1. Without that, every collection falls back to a non-moving conservative-scan cycle that never reaches the from-space scan — the gate would run and its subject would not.

Wiring checked directly: no continue-on-error, registered in gc_gate_wiring_check.py's GATES, and cancel-in-progress scoped to pull_request so main runs queue instead of cancelling each other.

The bug found on the way, which was necessary, not incidental

The clean runtime wasn't clean: ~1 in 40 record strings threw a false "dangling" hit. Root cause is arena_alloc_gc rounding total size up to 8-byte alignment, leaving up to 7 trailing bytes that are part of the allocation — inside GcHeader.size, which the collector treats as the object's true extent — but never written by anyone.

Harmless to every string API (all bounded by byte_len/capacity, never GcHeader.size), and harmless for other GC_TYPE_* because their construction writes every declared field — arrays already TAG_HOLE-fill their grow slack with a comment naming this exact hazard. But PERRY_GC_FROMSPACE_SCAN deliberately consults no layout state and scans every word up to GcHeader.size, so leftover bytes from whatever the block last held can decode as a plausible pointer. Fixed at string_storage_alloc/string_storage_alloc_longlived.

Without this the gate would have been randomly flaky on legitimate builds — which is its own kind of gate nobody can trust.

Maintainer action, not done here

Promotion to a required context is deliberately not in this PR. A new gate has never been green, so making it required immediately blocks every open PR; it needs one observed green run on main first. That second step being left undone is exactly how gc-stress ended up reporting failures without blocking anything.

cargo test -p perry-runtime --lib: 1945 passed, 0 failed. Lint 19/19.

@proggeramlug
proggeramlug merged commit f895cf2 into main Aug 9, 2026
14 of 16 checks passed
@proggeramlug
proggeramlug deleted the gc/7647-parse-churn-gate branch August 9, 2026 15:52
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.

Promote the tape=0 + from-space-scan parse-then-churn check to a CI gate for the layout-state family

1 participant