Skip to content

fix(repsel): make scalar replacement's method-call summary independent of PERRY_PTR_SHAPE_LOCALS - #7718

Merged
proggeramlug merged 3 commits into
mainfrom
gc/6984-ptr-shape-kill-switch
Aug 9, 2026
Merged

fix(repsel): make scalar replacement's method-call summary independent of PERRY_PTR_SHAPE_LOCALS#7718
proggeramlug merged 3 commits into
mainfrom
gc/6984-ptr-shape-kill-switch

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Fixes repsel: PERRY_PTR_SHAPE_LOCALS=0 breaks test_gap_repsel_ptr_shape_locals (TypeError, exit 1) with no GC involved — the Phase 3b kill switch does not disable Phase 5a #6984 (and closes out the item gc-stress gate is dark on main: #6925 left its gap file unregistered, and regressed repsel_ptr_shape_locals under PERRY_PTR_SHAPE_LOCALS=0 #6976 left open): PERRY_PTR_SHAPE_LOCALS=0 crashed test_gap_repsel_ptr_shape_locals.ts with TypeError: Cannot read properties of undefined (reading 'area'), with no GC involved. This is the kill switch for repsel Phase 3b/5a (Ptr<Shape> proven object locals + the proven-this method clones it feeds) — an OFF arm is supposed to be the safest cell in the matrix, and it wasn't.
  • Root cause was NOT in Phase 3b's provenance logic or its eligibility gates. collectors/ptr_shape.rs and collectors/proven_this.rs both correctly bail to an empty proof set when the switch is off. The bug was one level down, in a completely different, supposedly-orthogonal optimization: scalar replacement of new locals (collectors/escape_news.rs), and specifically its method-call summarizer in lower_call/scalar_method.rs.
    • That summarizer (try_lower_scalar_replaced_method_call) resolved a scalar-replaced receiver's class via type_analysis::predicates::receiver_class_name. That function's fallback arm — reached whenever Phase 3b's Ptr<Shape> proof is unavailable for any reason, not just the kill switch — returns the local's declared type name.
    • For const o: Shaped = new Impl(...) (an interface-typed local with new provenance — exactly the "predicates.rs narrowing" scenario test_gap_repsel_ptr_shape_locals.ts's ifaceLocal was written to test), that declared name is "Shaped", which is not a registered class.
    • simple_scalar_method_summary(ctx.classes, "Shaped", "area", 0) then fails to find the class, the summarizer gives up (return Ok(None)), and the caller falls through to the ordinary heap-object method-dispatch lowering — which reads the receiver from ctx.locals[receiver_id]. But scalar replacement had already turned that slot into a bare, never-initialized alloca (the heap allocation was elided; see let_stmt.rs's let dummy_slot = ctx.func.alloca_entry(DOUBLE); with no store). Reading it produces garbage that happens to decode as undefined, and o.area() throws.
  • Fix: resolve the receiver's class from ctx.non_escaping_news first — the exact map that already gated scalar replacement for that local in let_stmt.rs, keyed by the new expression's own class name, never the declared annotation — before falling back to receiver_class_name. This is provably safe: ctx.scalar_replaced.contains_key(receiver_id) being true already certifies (via the escape analysis's own mark_unstable_scalar_method_receivers/check_escapes_in_expr, both keyed off the same non_escaping_news class name) that simple_scalar_method_summary succeeds for this exact class+method, so the fix can only ever repair a wrong answer, never introduce one.

Why this was invisible until now

Two independent reasons (from #6976, which this PR's triage removal and CI arm close out):

  1. #6925 shipped test_gap_repsel_proven_this_frozen.ts without registering it in test-parity/gc_repsel_corpus.txt, which made scripts/gc_repsel_matrix.sh exit 3 before evaluating any cell — the gate was dark, not green. (Already fixed by #6977.)
  2. rep_ptr_shape_off — the arm that compiles with the switch off — is not in gc_repsel_matrix.sh's PR-gating subset (PR_ARMS), so no per-PR CI run ever exercised it. It only ran via --arms all, which fires on push-to-main/schedule, not per PR.

Decision: fix, not delete

Per CLAUDE.md's GC knob kill-policy ("every GC env knob either has a required CI arm exercising its OFF state, or it is deleted after one release of soak") and "a mode that still exists is a decision that hasn't been made" — the OFF state's failure here was a straightforward, fully-explained codegen defect in an unrelated optimization, not evidence that the representation itself only works with the optimization enabled. The switch has real bisection value (it is what let #6976's own triage separate "genuine repsel regression" from "everything else"), so fixing it and exercising it in CI is the right call, not deleting it.

Verification

  • Reproduced on main exactly as described:
    PERRY_PTR_SHAPE_LOCALS=0 perry test-files/test_gap_repsel_ptr_shape_locals.ts -o psoff
    ./psoff   # TypeError: Cannot read properties of undefined (reading 'area'); exit 1
    
  • Localized with --trace llvm on a minimal repro (interface + concrete class + for loop + return statement calling .area() twice) — confirmed the default (switch-on) LLVM IR fully scalar-replaces the receiver and inlines .area(), while the switch-off IR reads an alloca (%r4) that is declared but never stored anywhere in the function.
  • After the fix: both arms produce byte-identical output to the pinned Node oracle for test_gap_repsel_ptr_shape_locals.ts.
  • scripts/gc_repsel_matrix.sh --arms rep_ptr_shape_off --filter test_gap_repsel_ (the whole representation-selection corpus, 21 files) is 21/21 PASS, 0 FAIL, 0 UNVER, with genuine liveness under the arm's evacuating base (collected 21/21 reclaimed 21/21 moved-objects 21/21 copy-minor 21/21 — not a vacuous green from an inert arm).
  • Default (switch-on) build: byte-identical LLVM IR before and after the fix for the reproducer — the change is a pure no-op there, satisfying the "a knob may move only the sites of the representation it names" rule.
  • cargo fmt --all -- --check clean; cargo clippy -p perry-codegen --lib shows no new warnings (diffed against the pre-existing baseline); the existing collectors::scalar_method_dispatch unit tests (9/9) still pass.

CI arm

Adds .github/workflows/gc-ptr-shape-off-witness.yml, a per-PR job that runs scripts/gc_repsel_matrix.sh --arms rep_ptr_shape_off --filter test_gap_repsel_ — closing the exact hole described above. Checked against CLAUDE.md's "four ways a gate can be unable to fail":

  1. No continue-on-error, no || true, no pipe between the matrix and the shell's exit status.
  2. Deliberately NOT in branch protection's required contexts. A brand-new gate has never had a green run on main, and promoting it immediately would block every open PR. Promotion is a maintainer action, not part of this PR.
  3. concurrency cancels pull-request runs only; main runs are keyed on the commit SHA so they queue instead of cancelling each other (mirrors gc-moving-witnesses.yml's #7205 fix).
  4. scripts/gc_repsel_matrix.sh's own exit status already folds in the liveness gate (scripts/gc_matrix_liveness_check.py) alongside byte-exactness, so a green run means the arm actually compiled, actually ran under evacuation, and actually matched the oracle — not merely that nothing threw.

Also removes the now-obsolete #6976 triage entry for test_gap_repsel_ptr_shape_locals | rep_ptr_shape_off in test-parity/gc_repsel_triage.txt (per that issue's own "REMOVE THIS ENTRY when #6976 is fixed" instruction), and registers the new workflow in the two places that describe test-parity/gc_repsel_corpus.txt's runners (docs/src/testing/test-registration.md, scripts/check_test_registration.py's dark-test message).

Test plan

  • test_gap_repsel_ptr_shape_locals.ts passes under both PERRY_PTR_SHAPE_LOCALS on and off, byte-exact vs. the pinned Node oracle.
  • scripts/gc_repsel_matrix.sh --arms rep_ptr_shape_off --filter test_gap_repsel_ — 21/21 PASS, live under evacuation.
  • Default build's LLVM IR is byte-identical before/after the fix (no regression risk to the shipped configuration).
  • cargo fmt --all -- --check, cargo clippy -p perry-codegen --lib (no new warnings), existing scalar_method_dispatch unit tests.
  • CI (this PR's own checks, once opened).

Summary by CodeRabbit

  • Bug Fixes

    • Fixed crashes involving pointer-shape optimization and kill-switch configurations.
    • Improved method dispatch for scalar-replaced objects, including interface- and alias-typed values.
  • Testing

    • Added automated coverage for pointer-shape-disabled garbage-collection scenarios.
    • Promoted previously excluded representation-selection tests to required checks.
  • Documentation

    • Updated testing guidance and changelog entries.
  • Chores

    • Incremented the release version to 0.5.1415.

Ralph Küpper added 2 commits August 9, 2026 18:42
…t of PERRY_PTR_SHAPE_LOCALS

#6984/#6976: PERRY_PTR_SHAPE_LOCALS=0 crashed test_gap_repsel_ptr_shape_locals
with `TypeError: Cannot read properties of undefined`. The kill switch's own
eligibility gates (ptr_shape.rs, proven_this.rs) were correctly wired -- the
bug was one level down, in a supposedly-unrelated optimization. Scalar
replacement's method-call summarizer (lower_call/scalar_method.rs) resolved a
scalar-replaced receiver's class via receiver_class_name, whose fallback
(reached whenever Phase 3b's Ptr<Shape> proof is unavailable, for any reason)
returns the local's DECLARED type. For an interface-typed local with `new`
provenance that name is not a registered class, so the summarizer bailed and
the caller fell through to ordinary heap-object method dispatch -- which read
the receiver from a slot scalar replacement had already turned into a bare,
uninitialized alloca.

Fixed by resolving the class from ctx.non_escaping_news (the exact map that
already gated scalar replacement for that local) before falling back to
receiver_class_name. Verified byte-identical LLVM IR for the default
(switch-on) build before/after, and the whole test_gap_repsel_* corpus (21
files) PASS under rep_ptr_shape_off with genuine evacuation liveness.

Adds gc-ptr-shape-off-witness.yml, a per-PR CI arm exercising the switch's
OFF state across the repsel corpus -- rep_ptr_shape_off was previously only
in gc_repsel_matrix.sh's --arms all, which runs on push-to-main/schedule, not
per PR.
@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: 27 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: b38b409e-5ec5-422a-853f-78e718971a05

📥 Commits

Reviewing files that changed from the base of the PR and between e732f82 and fbdb562.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • .github/workflows/gc-ptr-shape-off-witness.yml
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/7718-ptr-shape-kill-switch.md
  • crates/perry-codegen/src/lower_call/scalar_method.rs
  • docs/src/testing/test-registration.md
  • scripts/check_test_registration.py
  • test-parity/gc_repsel_triage.txt
📝 Walkthrough

Walkthrough

The change fixes class resolution for scalar-replaced method receivers and adds a gated GitHub Actions workflow for the rep_ptr_shape_off representation-selection matrix. It also updates test registration, version records, and the changelog.

Changes

Ptr shape OFF witness

Layer / File(s) Summary
Scalar-replaced receiver resolution
crates/perry-codegen/src/lower_call/scalar_method.rs
Scalar-replaced method calls first use concrete class provenance from ctx.non_escaping_news, then fall back to the general receiver-class resolver.
Ptr shape OFF CI witness
.github/workflows/gc-ptr-shape-off-witness.yml
The workflow filters relevant pull-request changes, builds Perry and runtime archives, runs the rep_ptr_shape_off matrix with strict failure handling, and uploads JSON results.
Corpus registration and release records
docs/src/testing/test-registration.md, scripts/check_test_registration.py, CLAUDE.md, Cargo.toml, changelog.d/7718-ptr-shape-kill-switch.md
The new runner is registered and documented. The package version and documented version become 0.5.1415. The changelog records the receiver fix and CI coverage.

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

Sequence Diagram(s)

sequenceDiagram
  participant PullRequest
  participant RelevanceCheck
  participant PerryBuild
  participant RepselCorpus
  participant ArtifactUpload
  PullRequest->>RelevanceCheck: provide changed paths
  RelevanceCheck->>PerryBuild: enable relevant workflow run
  PerryBuild->>RepselCorpus: provide release archives
  RepselCorpus->>ArtifactUpload: produce JSON report
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#6911 — Extends related Ptr<Shape> representation-selection work with receiver resolution and CI coverage.
  • PerryTS/perry#7278 — Shares the test-registration and documentation updates for the GC representation-selection runner.
  • PerryTS/perry#7275 — Adds related GC representation-selection workflow and liveness coverage.

Suggested reviewers: thehypnoo, jdalton

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main fix: making scalar replacement method-call summaries independent of the pointer-shape setting.
Description check ✅ Passed The description is detailed and covers the summary, changes, issue context, verification, CI arm, and test plan, but it omits the dedicated Related issue section.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 gc/6984-ptr-shape-kill-switch

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.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merging as v0.5.1415

The root cause is better than the issue's own hypothesis, and materially wider than a kill-switch bug.

It is not in Phase 3b. collectors/ptr_shape.rs and collectors/proven_this.rs both gate correctly on ptr_shape_locals_enabled() and yield an empty proof set when the switch is off. The defect is one level down, in a supposedly orthogonal optimization:

try_lower_scalar_replaced_method_call resolved a scalar-replaced receiver's class through receiver_class_name, whose fallback arm returns the local's declared type name. For const o: Shaped5a = new Impl5a(...) that is "Shaped5a" — an interface, not a registered class. The lookup fails, simple_scalar_method_summary returns None, the function bails, and the caller falls through to ordinary heap-object dispatch — which reads ctx.locals[receiver_id]. But scalar replacement had already made that slot a bare alloca_entry(DOUBLE) with no store. Reading it yields garbage that decodes as undefined.

The part that matters beyond this ticket: that fallback fires whenever the Ptr<Shape> proof is unavailable for any reason — not only under PERRY_PTR_SHAPE_LOCALS=0. The kill switch did not cause this bug; it made a latent default-path hazard deterministic and therefore findable. That is exactly the argument for keeping kill switches exercised rather than deleting them, and it is why "fix, not delete" is the right call here.

The fix resolves the class from ctx.non_escaping_news — the same map that gated scalar replacement for that local, keyed by the new expression's own class name rather than the annotation. Provably safe in one direction: scalar_replaced.contains_key(receiver_id) already certifies, via escape analysis keyed on that same map, that the summary succeeds for that exact class+method. It can repair a wrong answer, never introduce one.

Diagnosis method worth copying: --trace hir --focus ifaceLocal showed HIR identical on both arms, proving this was purely a codegen decision and saving a pointless hunt through lowering; --trace llvm then showed the off-arm reading an alloca declared but never stored anywhere in the function.

Verification

  • Both arms byte-identical to the Node oracle on test_gap_repsel_ptr_shape_locals.ts.
  • Default-arm LLVM IR byte-identical before/after — a pure no-op on the shipping path.
  • gc_repsel_matrix.sh --arms rep_ptr_shape_off: 21/21 PASS, 0 FAIL, 0 UNVER, with genuine liveness under evacuation (collected 21/21 moved-objects 21/21) — not a run that passed by never collecting.
  • Lint 19/19.

CI arm added (gc-ptr-shape-off-witness.yml) so the OFF state is exercised per PR; it was previously only in --arms all, which fires on push-to-main and schedule, not on PRs. Promotion to a required context stays a maintainer action — a new gate has never been green, so making it required immediately would block every open PR.

@proggeramlug
proggeramlug merged commit 1077ce6 into main Aug 9, 2026
0 of 17 checks passed
@proggeramlug
proggeramlug deleted the gc/6984-ptr-shape-kill-switch branch August 9, 2026 16: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

1 participant