Skip to content

test(gc): make the POINTER_FREE misdeclaration hazard detectable (#7635) - #7643

Merged
proggeramlug merged 4 commits into
mainfrom
fix/7635-layout-state-probe
Aug 8, 2026
Merged

test(gc): make the POINTER_FREE misdeclaration hazard detectable (#7635)#7643
proggeramlug merged 4 commits into
mainfrom
fix/7635-layout-state-probe

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Closes #7635.

The question

#7635 sabotaged layout_finish_deferred_boxed_object(ptr, saw_pointer) to
(ptr, false) — every JSON-parsed record claiming POINTER_FREE while holding
heap strings, the stranded-live-child hazard the layout state exists to prevent
— and got byte-identical correct output under PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 and under PERRY_GC_FORCE_EVACUATE=1, with
copying minors and retired quarantine sets observed live. Is the hazard
detectable at all?

Answer: yes, and the instruments were never at fault

js_json_parse routes a top-level array of 1 KB–16 MB through the lazy tape
(json_tape, default since #179). parse_object — the function carrying the
sabotage — therefore does not run at JSON.parse time; it runs when an element
is first read. The probe read its records only after the churn, so every
misdeclared record was materialised after the last collection.

Confirmed rather than inferred: a temporary audit in
heap_payload_slot_selection on the sabotaged build counted zero objects in
POINTER_FREE state with pointer-bearing payload words ever handed to the
collector, across the whole run. Nothing was stranded because nothing was there.
This is CLAUDE.md's hazard 4 — the gate ran but its subject never did — applied
to a probe rather than a job.

Re-run so the misdeclared records actually live across a collection (sabotaged
vs clean runtime, perry-dev, macOS arm64, PERRY_NO_AUTO_OPTIMIZE=1):

arm clean sabotaged
default (lazy), read after churn exit 0 exit 0, byte-identical, dangling=0
PERRY_JSON_TAPE=0, read after churn exit 0 SIGSEGV (139); dangling=8000 owners=4000 on the first scanned cycle (vs dangling=0 clean); PERRY_GC_PROTECT_FROMSPACE=1 prints FAULT: signal 10 at 0x…
default (lazy), records touched BEFORE the churn exit 0 7,872 of 8,000 values read back wrong

dangling=8000 owners=4000 is exactly 4,000 records × 2 pointer fields.

One instrument is structurally blind and it is worth naming:
PERRY_GC_VERIFY_EVACUATION walks the same enumeration the rewrite pass walks,
i.e. it asks this very layout state which slots exist.
PERRY_GC_FROMSPACE_SCAN=1 is the layout-independent one — a whole-payload
word scan consulting no root enumeration and no layout state — and it is the
knob to reach for on this hazard class.

What ships

crates/perry-runtime/src/gc/tests/copying/deferred_finalize_7635.rs — four
tests that need no workload at all, so they cannot be defeated by a lazy path, a
GC that did not happen to run, or conservative-scan residue:

  1. finalize_settles_pointer_free_or_unknown_and_nothing_else — the finalize's
    two exact outcomes (no pointer ⟹ keep the birth state; any pointer ⟹
    GC_LAYOUT_UNKNOWN, no mask, no stranded descriptor).
  2. a_materialised_record_keeps_its_children_traced_and_rewritten_7635 — the
    materialiser's construction loop byte for byte; the collector must enumerate
    every field, and a copying minor must relocate both children and rewrite both
    slots. Gated on copied_objects >= 3 so a cycle that moved nothing cannot
    pass.
  3. json_parse_record_keeps_its_string_values_traced_and_rewritten_7635 — the
    same invariant through the real js_json_parse entry point, so the
    finalize call site in json/parser.rs is covered and not merely the helper.
    gc: forcing POINTER_FREE on a pointer-bearing object strands nothing — our zeal/protect instruments do not discriminate the layout-state hazard #7635's sabotage was applied at that call site and tests 1–2 stay green
    through it.
  4. a_misdeclared_pointer_free_record_strands_its_child — the sabotage arm,
    made permanent
    : the identical construction with saw_pointer forced to
    false, asserting the collector enumerates ZERO slots and that correcting the
    state alone makes the same record fully enumerable. Asserted on the
    enumerator, so it leaves no stale pointer behind.

Sabotage-verified in both directions, locally:

The doc comment on GC_LAYOUT_POINTER_FREE now records what can and cannot
verify a claim about this state, so the next PR in this family does not cite
"clean under zeal + from-space protect" as evidence without first showing the
subject existed during a collection.

No GC behaviour changes; no new knobs; no codegen changes.

Validation

  • cargo test -p perry-runtime --lib --no-fail-fast (perry-dev)
  • cargo check --all-targets
  • cargo fmt --all -- --check
  • the full lint script set from .github/workflows/test.yml:
    workspace_architecture (+self-test), check_file_size.sh, binding_pins,
    gc_store_site_inventory (+self-test), addr_class_inventory (+self-test),
    class_id_collisions, raw_handle_debt (+self-test), gap_snapshot
    self-test, parity_known_failures (+self-test, --audit),
    gc_gate_wiring_check (+self-test), gc_matrix_liveness_check (+self-test,
    --check-registry), check_test_registration (+self-test) — all exit 0.

Root-dominance is not re-run: nothing under perry-codegen changed.

Follow-up worth considering (not in this PR)

The end-to-end shape is now a known-good detector: PERRY_JSON_TAPE=0 +
PERRY_GC_FROMSPACE_SCAN=1 over a parse-then-churn workload, asserting
dangling=0 and that a copying minor actually ran. That would make a real
gate for the whole layout-state family. Per CLAUDE.md a new gate must be run
once and then promoted to required, which is a separate change.

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability when processing JSON data with deferred materialization.
    • Ensured pointer-bearing records remain tracked and correctly updated during memory cleanup and relocation.
    • Added safeguards and regression coverage for incorrect pointer-free layout declarations.
  • Documentation

    • Documented deferred materialization behavior, layout finalization assumptions, verification limits, and diagnostic guidance.
  • Chores

    • Updated the application version to 0.5.1365.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: eb6204b3-c671-4129-905a-e7dfec16b5bb

📥 Commits

Reviewing files that changed from the base of the PR and between 0423a6b and f5875c5.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • CLAUDE.md
  • Cargo.toml

📝 Walkthrough

Walkthrough

The change adds copying-GC regression tests for deferred JSON record layout finalization. It verifies pointer-free and pointer-bearing layouts, child-slot enumeration, relocation, and sabotage detection. It also documents verification limits and updates the workspace version.

Changes

Deferred JSON GC layout validation

Layer / File(s) Summary
Layout invariant and test foundation
crates/perry-runtime/src/gc/layout.rs, crates/perry-runtime/src/gc/tests/copying.rs, crates/perry-runtime/src/gc/tests/copying/deferred_finalize_7635.rs, changelog.d/*, CLAUDE.md, Cargo.toml
Documents GC_LAYOUT_POINTER_FREE, its verification limits, and deferred JSON assumptions. Registers the regression module, adds inspection helpers, and updates the workspace version.
Normal finalization and copying behavior
crates/perry-runtime/src/gc/tests/copying/deferred_finalize_7635.rs
Tests deferred record finalization, pointer-free and pointer-bearing layouts, copying-minor relocation, slot rewriting, and the real js_json_parse path.
Sabotaged pointer-free regression
crates/perry-runtime/src/gc/tests/copying/deferred_finalize_7635.rs
Forces a pointer-bearing record into GC_LAYOUT_POINTER_FREE, verifies that child enumeration omits its fields, and confirms that layout_mark_unknown restores enumeration.

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

Possibly related PRs

  • PerryTS/perry#7633: Introduced the deferred JSON materializer layout finalization covered by these regressions.
  • PerryTS/perry#7553: Added the lazy JSON tape and materialization flow tested here.
  • PerryTS/perry#7644: Extends the same POINTER_FREE and deferred-finalization regression work.

Suggested labels: bug

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: adding GC regression coverage to detect POINTER_FREE misdeclarations.
Description check ✅ Passed The description provides a detailed summary, related issue, test coverage, validation commands, and scope, although it does not follow every template heading.
Linked Issues check ✅ Passed The PR addresses #7635 by accounting for lazy materialization, documenting instrument limitations, adding deterministic sabotage detection, and covering the real JSON parser path.
Out of Scope Changes check ✅ Passed The changes remain within scope: they add regression tests and documentation without changing GC behavior, knobs, or code generation.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7635-layout-state-probe

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 8, 2026 15:01
#7635 sabotaged `layout_finish_deferred_boxed_object(ptr, saw_pointer)` to
`(ptr, false)` -- every JSON-parsed record claiming POINTER_FREE while
holding heap strings -- and got byte-identical correct output under
PERRY_GC_ZEAL + PERRY_GC_PROTECT_FROMSPACE and under
PERRY_GC_FORCE_EVACUATE, with copying minors and retired quarantine sets
observed live. Those knobs do not discriminate this hazard.

Four tests that do, sabotage-verified in both directions: the child-slot
enumerator (deterministic, no GC timing), relocation across a copying
minor, the finalize's two exact outcomes, and the same invariant driven
through the real js_json_parse entry point so the json/parser.rs call
site is covered rather than only the helper.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
#7635's audit forced every JSON-parsed record to POINTER_FREE while it
held heap strings and got byte-identical correct output under zeal +
from-space protect and under force-evacuate. The instruments were not at
fault: js_json_parse is LAZY for 1 KB-16 MB top-level arrays (json_tape),
so parse_object runs when an element is first READ, and the probe read
its records only after the last collection. A traced-object audit on the
sabotaged build saw zero POINTER_FREE objects with pointer-bearing
payload words -- nothing was stranded because nothing was there.

Under PERRY_JSON_TAPE=0 the same sabotage SIGSEGVs, PERRY_GC_FROMSPACE_SCAN
reports dangling=8000 owners=4000 (exactly 4000 records x 2 fields, vs 0
clean), and PERRY_GC_PROTECT_FROMSPACE names the faulting address. With
the records merely touched before the churn, the default lazy path reads
back 7,872 of 8,000 values wrong.

Written onto the constant so the next PR in this family does not cite
"clean under zeal + protect" as evidence without first showing the
subject existed during a collection, and prefers the layout-independent
PERRY_GC_FROMSPACE_SCAN. PERRY_GC_VERIFY_EVACUATION is genuinely blind
here -- it walks the same enumeration the rewrite pass walks.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix

@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-runtime/src/gc/tests/copying/deferred_finalize_7635.rs`:
- Around line 114-132: Update materialise_record to root obj immediately after
js_object_alloc, keep that temporary root active while fresh_string allocates
each child, and reload the record’s rewritten address from the root before every
store_object_field_slot_layout_deferred call. Clear the temporary root after
layout_finish_deferred_boxed_object completes, preserving the existing
finalization behavior.
🪄 Autofix

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: edd549ce-6606-4f6c-854b-2dca142b9b46

📥 Commits

Reviewing files that changed from the base of the PR and between d3a4b2e and 0423a6b.

📒 Files selected for processing (5)
  • changelog.d/7633-json-materialiser-layout-deferred.md
  • changelog.d/7643-pointer-free-misdeclaration-detectable.md
  • crates/perry-runtime/src/gc/layout.rs
  • crates/perry-runtime/src/gc/tests/copying.rs
  • crates/perry-runtime/src/gc/tests/copying/deferred_finalize_7635.rs

Comment on lines +114 to +132
unsafe fn materialise_record(honest_finalize: bool) -> *mut ObjectHeader {
let obj = crate::object::js_object_alloc(0, FIELD_VALUES.len() as u32);
assert_eq!(
layout_state_of(obj as usize),
GC_LAYOUT_POINTER_FREE,
"test premise: a fresh record is born POINTER_FREE, which is why the \
finalize is the only thing that can move it off that state"
);
let mut saw_pointer = false;
for (index, bytes) in FIELD_VALUES.iter().enumerate() {
let child = fresh_string(bytes);
saw_pointer |=
crate::object::store_object_field_slot_layout_deferred(obj, index, string_bits(child));
}
assert!(
saw_pointer,
"test premise: storing heap strings must be reported as pointer-bearing"
);
layout_finish_deferred_boxed_object(obj as usize, saw_pointer && honest_finalize);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Root obj before allocating field strings.

Line 115 creates obj, but line 124 can allocate and collect before the next store. obj exists only as a raw Rust pointer during that collection point. A copying collection can reclaim or move the record, and the later store can dereference a stale pointer.

Root the record immediately after allocation. Reload its rewritten address after each allocation before storing the next field. Clear the temporary root after finalization.

As per coding guidelines, “GC-managed values must remain correctly rooted across collection points.” Based on learnings, raw Rust pointer locals are neither GC roots nor reliable pins.

🤖 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/tests/copying/deferred_finalize_7635.rs` around
lines 114 - 132, Update materialise_record to root obj immediately after
js_object_alloc, keep that temporary root active while fresh_string allocates
each child, and reload the record’s rewritten address from the root before every
store_object_field_slot_layout_deferred call. Clear the temporary root after
layout_finish_deferred_boxed_object completes, preserving the existing
finalization behavior.

Sources: Coding guidelines, Learnings

@proggeramlug
proggeramlug force-pushed the fix/7635-layout-state-probe branch from 0423a6b to f5875c5 Compare August 8, 2026 13:11
@proggeramlug

proggeramlug commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Audit — merged as v0.5.1365

I reproduced the decisive claim independently before reading the tests, and it corrects my finding, not the instruments'.

Reproduced, sabotaging json/parser.rs myself (both call sites → saw_pointer = false, perry-dev, PERRY_NO_AUTO_OPTIMIZE=1, arm64):

arm clean sabotaged
default (lazy tape) exit 0, correct exit 0, byte-identical correct
PERRY_JSON_TAPE=0 exit 0, correct exit 138 = SIGBUS
PERRY_JSON_TAPE=0 PERRY_GC_FROMSPACE_SCAN=1 dangling=8000 owners=4000, never_dirty=8000, offenders named with space=Survivor1 → NurseryEden DANGLING

owners=4000 is exactly the 4,000 records, dangling=8000 exactly their two fields each. So the hazard is real, detectable, and my #7635 "no instrument can see it" conclusion was wrong for the reason given here: js_json_parse is lazy for 1 KB–16 MB top-level arrays, parse_object runs on first read, and my probe read after the churn. CLAUDE.md hazard 4 — "the gate runs but its subject never did" — applied to a probe rather than a job. Worth naming plainly: I wrote that hazard into CLAUDE.md and then walked into it.

Sabotage-verified in both directions, independently of your run:

What makes these tests worth having: they are workload-free, so no lazy path, GC-that-didn't-run, or conservative-scan residue can defeat them; and both positive tests assert copied_objects >= 3 before concluding, so a cycle that moved nothing can't score green. The permanent sabotage arm is the piece that makes the positive tests detectors rather than formalities — and it restores state via layout_mark_unknown before the guard drops, so it leaves no landmine for a later cycle on that thread.

Gates: I enumerated the lint job from test.yml rather than trusting a checklist — 20/20 green (that extraction turned up parity_known_failures.py --audit and gap_snapshot.py --self-test, neither of which was in my memorized set). cargo fmt --all --check clean, cargo test -p perry-runtime --lib --no-fail-fast 1906 passed / 0 failed. Root-dominance correctly skipped — no codegen changed.

Two things I'm keeping from this beyond the tests. The GC_LAYOUT_POINTER_FREE doc comment now says which instrument is blind and why (PERRY_GC_VERIFY_EVACUATION walks the same enumeration the rewrite pass walks — it asks the state under test what to check), which is the kind of thing that is otherwise re-derived once per incident. And the qualification you added to #7633's fragment is the right call: its argument was sound, its probe could not have shown it either way, and the fragment now says so instead of leaving a clean-under-zeal sentence for the next change to inherit as evidence.

Your suggested follow-up — PERRY_JSON_TAPE=0 + PERRY_GC_FROMSPACE_SCAN=1 over parse-then-churn, asserting dangling=0 and that a copying minor ran — is filed as #7647. Correctly a separate change: a new gate has never been green, so it gets one observed run before promotion.

@proggeramlug
proggeramlug merged commit 9ee9b4c into main Aug 8, 2026
11 of 12 checks passed
@proggeramlug
proggeramlug deleted the fix/7635-layout-state-probe branch August 8, 2026 13:11
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: forcing POINTER_FREE on a pointer-bearing object strands nothing — our zeal/protect instruments do not discriminate the layout-state hazard

1 participant