Skip to content

gc_roots: walk the root stack by value; name the entry-bridge InvalidLoop reason - #987

Merged
youknowone merged 4 commits into
mainfrom
ec-wiring
Aug 3, 2026
Merged

gc_roots: walk the root stack by value; name the entry-bridge InvalidLoop reason#987
youknowone merged 4 commits into
mainfrom
ec-wiring

Conversation

@youknowone

@youknowone youknowone commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Two review responses from PR #966, plus the diagnostic that found them.

gc_roots: walk the root stack by value

walk_shadow_stack_cell already re-reads base for every slot so a re-entrant
push that calls grow cannot strand the walk in the freed buffer — but it still
handed the visitor a &mut PyObjectRef pointing into that buffer. A visitor
that pins a root and then writes through its reference writes into the
allocation grow had already freed.

The slot is now read into a local, the visitor gets &mut to the local, and the
value is stored back through a re-read base. PyObjectRef is
*mut PyObject, so it is a register copy either way, and grow copies the live
prefix, so the index still names the same slot. walk_shadow_stack and
walk_shadow_stack_area both go through this one loop.

cargo test --release -p pyre-object gc_roots: 10 passed, 0 failed.

stack_check: record why MAX_RECURSION_LIMIT is the bound

The review also asked for a bound on root_stack_depth before the eager
allocation. That bound already exists and is upstream's: shadowstack.py:351-364
resizes on the spot, and vm.py:83-88 adds its 106 ceiling for exactly that
reason — "because huge values cause huge shadowstacks to be allocated (or
MemoryErrors)". MAX_RECURSION_LIMIT is that same 10
6. A tighter bound would
size the root stack below the recursion limit it exists to serve, so the change
here is the citation, not a new clamp.

jit: name the InvalidLoop reason in the entry-bridge log

compile_entry_bridge discarded the InvalidLoop payload, so the log said only
that the bridge was abandoned. The reason string is what separates a
speculative-field rejection from a quasi-immutable invalidation, and both reach
that arm. With it, exception_reraise_tb_depth_jitstress's 1198 aborts resolved
1:1 to InvalidLoop(quasi immutable field changed during tracing) — the failure
#977 has since fixed.

Summary by CodeRabbit

  • Bug Fixes

    • Improved diagnostic logging for invalid loop handling by including associated error details.
    • Improved garbage-collection root handling during stack growth, helping prevent stale references during re-entrant operations.
    • Improved speculative optimization validation and error reporting with more specific failure details.
    • Improved field resolution for layouts with incomplete metadata, supporting more reliable JIT compilation.
  • Documentation

    • Added documentation clarifying recursion-limit behavior and root-stack memory allocation.

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The PR adds specific speculative-field validation errors, improves field descriptor resolution through the GC cache, and updates InvalidLoop logging. It also documents eager root-stack allocation and protects shadow-stack traversal from re-entrant root-buffer relocation.

Changes

JIT validation and diagnostics

Layer / File(s) Summary
Speculative-field validation diagnostics
majit/majit-backend/src/model.rs, majit/majit-metainterp/src/optimizeopt/mod.rs, majit/majit-metainterp/src/pyjitpl.rs
protect_speculative_field returns specific static error messages. The optimizer logs and propagates each reason to InvalidLoop. The entry-bridge log prints the error payload explicitly.
Cached field descriptor resolution
majit/majit-metainterp/src/pyjitpl/dispatch.rs
Parent-associated fields without serialized descriptors use cached parent size and field descriptors. Parentless fallback resolution can emit a diagnostic log.

Root-stack safety

Layer / File(s) Summary
Relocation-safe shadow-stack traversal
pyre/pyre-interpreter/src/stack_check.rs, pyre/pyre-object/src/gc_roots.rs
Comments document eager root-stack allocation and its MAX_RECURSION_LIMIT bound. Shadow-stack traversal visits a local root copy and writes through the current buffer address.

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

Possibly related PRs

Poem

A rabbit checks each field with care,
And leaves clear reasons in the air.
Root stacks grow, but roots stay sound,
Even when buffers move around.
Safe hops through the JIT ground.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the root-stack safety change and the entry-bridge InvalidLoop diagnostic change.
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.
✨ 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 ec-wiring

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.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit dedff68).
Updated: 2026-08-03T07:31:18.273Z

Files in the reviewed diff
majit/majit-backend/src/model.rs
majit/majit-metainterp/src/optimizeopt/mod.rs
majit/majit-metainterp/src/pyjitpl.rs
majit/majit-metainterp/src/pyjitpl/dispatch.rs
pyre/pyre-interpreter/src/stack_check.rs
pyre/pyre-object/src/gc_roots.rs

1. Regressions to PyPy parity introduced by this patch

None.

2. Other mismatches introduced by this patch

  • majit/majit-metainterp/src/pyjitpl/dispatch.rs:222 ↔ rpython/jit/backend/llsupport/descr.py:112: the new empty-layout recovery calls get_size_descr(..., false), hard-coding immutable_flag=False; PyPy derives it from heaptracker.is_immutable_struct(STRUCT). An immutable structure entering this path loses its immutable descriptor property.

3. Pre-existing mismatches (already present before this patch)

  • majit/majit-metainterp/src/pyjitpl/dispatch.rs:248 ↔ rpython/jit/backend/llsupport/descr.py:218: the existing parentless fallback constructs a standalone make_field_descr; PyPy’s get_field_descr always assigns fielddescr.parent_descr = get_size_descr(...) at line 238. Consequently this fallback cannot participate in parent-dependent virtualization or speculative type validation.

4. Structural adaptations

  • pyre/pyre-object/src/gc_roots.rs:706 ↔ rpython/memory/gctransform/shadowstack.py:53: Rust copies a root into a local, visits it, then writes it back, whereas RPython passes the in-stack address directly to the collector. This is a Rust memory-safety adaptation: a visitor may grow and relocate the Rust root-stack buffer, invalidating an in-buffer mutable reference.

@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 `@pyre/pyre-object/src/gc_roots.rs`:
- Around line 706-714: Extend the existing growth test for the stack-root walk
to grow the buffer inside the visitor, assign a different root through slot,
then assert shadow_stack_get(index) after the walk. Keep the assertion focused
on confirming the mutation is written to the re-read buffer slot exercised by
the write-back in the root-walking function.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1dac4c78-1157-473e-8058-9c4ecefef44a

📥 Commits

Reviewing files that changed from the base of the PR and between c089185 and da0b538.

📒 Files selected for processing (3)
  • majit/majit-metainterp/src/pyjitpl.rs
  • pyre/pyre-interpreter/src/stack_check.rs
  • pyre/pyre-object/src/gc_roots.rs

Comment thread pyre/pyre-object/src/gc_roots.rs

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a249b7fefb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +220 to +222
let struct_key = majit_ir::descr::LLType::Struct(p.type_id);
let mut gc = majit_ir::descr::gc_cache().lock().unwrap();
gc.get_size_descr(struct_key.clone(), p.size, p.vtable as usize, false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep type-id-less parents out of the keyed cache

When an empty parent spec has p.type_id == 0, this publishes it under the shared LLType::Struct(0) key, even though zero is explicitly the no-identity sentinel (simple_descr_group_from_bh_size therefore fresh-mints such descriptors). Two unrelated type-id-less parents will consequently reuse the first size descriptor and field namespace, so speculative field validation can reject a valid bridge against the wrong size/vtable or attach a field to the wrong parent. Handle zero via the existing non-keyed fresh-mint path rather than caching it.

AGENTS.md reference: AGENTS.md:L194-L196

Useful? React with 👍 / 👎.

…ngle the visitor's slot

`walk_shadow_stack_cell` re-reads `base` for every slot so a re-entrant push
that calls `grow` cannot strand the walk in the freed buffer, but it still
handed the visitor a `&mut PyObjectRef` pointing into that buffer. A visitor
that pins a root and then writes through its reference would write into the
allocation `grow` had already freed.

Read the slot instead, hand the visitor `&mut` to the local, and store it back
through a re-read `base`. `PyObjectRef` is `*mut PyObject`, so this is a
register copy either way, and `grow` copies the live prefix, so the index still
names the same slot. Both walkers (`walk_shadow_stack` and
`walk_shadow_stack_area`) go through this one loop.

stack_check: record why `MAX_RECURSION_LIMIT` is the bound on the eager root
stack allocation. `shadowstack.py:351-364` resizes on the spot, and
`vm.py:83-88` adds its 10**6 ceiling for exactly that reason; a tighter bound
would size the root stack below the recursion limit it exists to serve.

Assisted-by: Claude
`compile_entry_bridge` discarded the `InvalidLoop` payload, so its log line
reported only that the bridge was abandoned. The reason string is what
distinguishes a speculative-field rejection from a quasi-immutable
invalidation, and both reach this arm.

Assisted-by: Claude
`protect_speculative_field` fails closed on every path where the type-validity
verdict cannot be produced — a null gcptr, a missing `parent_descr`, an
unresolvable typeid or subclass range, or a genuine type mismatch. It reported
all of them as a bare `Err(())`, so the `InvalidLoop` the caller signals said
only that a speculative read was refused.

Return `&'static str` naming the branch instead, and log the field's identity
next to it under `MAJIT_LOG`. On `exception_args_virtual` this resolves all six
refusals to `field descr has no parent_descr` for an unnamed
`FieldDescr(offset=32, size=8, Int)`, which is a parentless descr from the
`field_descr_ref_from_bh` placeholder path rather than an ill-typed pointer.

No counter moves: the four jit-stats fixtures measure identically before and
after.

Assisted-by: Claude
…tened field list

`field_descr_ref_from_bh` only reached `get_field_descr` when the attached
parent spec carried a non-empty `all_fielddescrs`; with an empty list it fell
through to the parentless `make_field_descr` placeholder even though the
producer had supplied the struct's size, type id and vtable.

`descr.py:238 parent_descr = get_size_descr(gccache, STRUCT, vtable)` derives
the parent from the STRUCT, not from the flattened field list — that list only
supplies `index_in_parent` (`descr.py:228`). Mint the SizeDescr from what the
producer did attach and route the field through `get_field_descr`, so the
descr carries a parent in this case too. The remaining parentless path (no
parent spec at all) is unchanged and now logs the field it fell back on.

A parentless descr made `protect_speculative_field` (`llmodel.py:560`, which
asserts the parent exists) fail closed with no type to validate against,
deferring an `InvalidLoop` that abandoned the bridge and left `compile_loop`
to abort on `has_compiled_targets`.

Both fixtures that had drifted return to their committed baselines:

  exception_args_virtual      loops_aborted 3 -> 0, guard_failures 1002 -> 401,
                              loops_compiled 2 -> 1
  list_length_hint_validate   loops_aborted 34 -> 14, guard_failures 4923 -> 828,
                              bridges_compiled 3 -> 4

check.py: 370/370 on dynasm and 370/370 on cranelift. cargo test on
majit-metainterp, majit-backend and pyre-object: 1840 passed, 0 failed.

Assisted-by: Claude

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
majit/majit-metainterp/src/pyjitpl/dispatch.rs (1)

8511-8524: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Validate typed call arguments before dispatch.

arg_types.get(i) treats missing metadata as non-float, so extra argument values can be sent with too few type entries. Any extra trailing metadata is discarded because the loop follows args. Add args.len() == arg_types.len() validation before both host and native dispatch, match Type::Int and Type::Ref explicitly, and reject invalid metadata such as Type::Void.

🤖 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 `@majit/majit-metainterp/src/pyjitpl/dispatch.rs` around lines 8511 - 8524,
Update call_float_function to validate that args.len() equals arg_types.len()
before either residual_host_call or native dispatch. Validate each metadata
entry explicitly, accepting only Type::Float, Type::Int, and Type::Ref, while
rejecting Type::Void or any unsupported type; then dispatch using the validated
argument metadata without silently treating missing entries or trailing metadata
as valid.
🤖 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.

Outside diff comments:
In `@majit/majit-metainterp/src/pyjitpl/dispatch.rs`:
- Around line 8511-8524: Update call_float_function to validate that args.len()
equals arg_types.len() before either residual_host_call or native dispatch.
Validate each metadata entry explicitly, accepting only Type::Float, Type::Int,
and Type::Ref, while rejecting Type::Void or any unsupported type; then dispatch
using the validated argument metadata without silently treating missing entries
or trailing metadata as valid.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e0721b8c-f528-4b00-bdcd-a00b2eee16af

📥 Commits

Reviewing files that changed from the base of the PR and between da0b538 and dedff68.

📒 Files selected for processing (6)
  • majit/majit-backend/src/model.rs
  • majit/majit-metainterp/src/optimizeopt/mod.rs
  • majit/majit-metainterp/src/pyjitpl.rs
  • majit/majit-metainterp/src/pyjitpl/dispatch.rs
  • pyre/pyre-interpreter/src/stack_check.rs
  • pyre/pyre-object/src/gc_roots.rs

@youknowone
youknowone merged commit 6a005c4 into main Aug 3, 2026
19 checks passed
@youknowone
youknowone deleted the ec-wiring branch August 3, 2026 09:39
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