Skip to content

jit: make JIT-created interpreter frames GC-owned (retire FrameArena and the portal frame rebuild) - #794

Merged
youknowone merged 13 commits into
mainfrom
ec-wiring
Jul 26, 2026
Merged

jit: make JIT-created interpreter frames GC-owned (retire FrameArena and the portal frame rebuild)#794
youknowone merged 13 commits into
mainfrom
ec-wiring

Conversation

@youknowone

@youknowone youknowone commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Makes every JIT-created interpreter frame an ordinary GC object. FrameArena had no upstream analogue: space.createframe allocates a fresh PyFrame(W_Root) per call (baseobjspace.py:799-801, pyframe.py:52) and lifetime is the GC's — ec.leave (executioncontext.py:91-107) frees nothing.

Commits

  1. jit: enter/leave the inlined callee frame around its concrete residual calls — names the frame a sub-walk executes concretely so each residual it runs can enter/leave it on the interpreter frame chain.
  2. jit: allocate JIT callee frames as GC objects instead of recycling arena slots — deletes FrameArena.
  3. jit: run the CALL_ASSEMBLER force path on the callee frame it is handed — deletes the portal's frame rebuild.
  4. interp: remove the unused raw-value PyFrame constructors
  5. jit: drop the unread concrete_frame from PendingInlineFrame

The recycle bug (commit 2)

scratchpad/s4_arena_tb_recycle.py — a self-recursive fib(n, k) whose base case raises ZeroDivisionError, an outer function that walks e.__traceback__ and retains every tb_frame, then more fib churn, then re-reads f_locals on the held frames:

pypy3 held 38 mutated 0
pyre-dynasm held 38 mutated 15 — silent data corruption
pyre-dynasm PYRE_NO_JIT=1 held 38 mutated 0
pyre-cranelift non-unwinding panic in gc_alloc_nursery_shim, abort

jit_drop_callee_frame unconditionally returned the slot; put disarmed it (removing it from the root walk) and the next call at that depth overwrote it in place, including drop_in_place of the old locals array — which is what made cranelift crash in PyFrame::fast2locals.

escaped() cannot protect it even in principle: the bit is set lazily when Python reads tb.tb_frame, long after the drop.

The four create sites now call one alloc_callee_frame = FrameBox::new(PyFrame::new_for_call_with_closure_and_globals_obj(..., OldGenGc)), and jit_drop_callee_frame only unroots. Between create and drop the frame's only root is LIVE_CALLEE_FRAMES, so that walker must mark the frame object itself, not just walk its slots — slot-walking alone (what the arena needed) would let a major collection sweep the block compiled code is running on.

pytraceback_object_custom_trace already forwards tb.frame guarded by try_gc_owns_object, so making the frame GC-owned lights that edge up on its own; no PYTRACEBACK_GC_PTR_OFFSETS change was needed.

★The reason my first probes found nothing: the arena is only entered when recursive_force_cache_safe accepts the callee, and it rejects any LoadGlobal/LoadName that does not name the function itself. sys._getframe() needs LOAD_GLOBAL sys, so such a callee never uses the arena — it escapes its frame via a traceback, not sys._getframe.

The portal rebuild (commit 3)

jit_force_callee_frame read pycode/w_globals/execution_context off the incoming pointer by raw offset and built a fresh PyFrame with an empty argument slice. Two defects on one line: the rebuilt frame was a Rust stack local, so portal_runnerinstall_current_frame published a C-stack address as CURRENT_FRAME and ec.topframeref; and every parameter slot was PY_NULL with last_instr reset to -1. The repo already recorded the second failure once — the cranelift comment about w_list_append's sibling path notes "left locals_w[0] uninitialized … the stale n value drove the self-recursive CA into an unbounded loop" — and fixed it by deleting one call site, leaving the body.

The premise justifying the rebuild — "the callee frame may be a nursery-allocated JitFrame-like block" — is false. emit_new_pyframe_inline_with_params emits NewWithVtable + SetfieldGc with the real PyFrame layout, which is why the raw offsets worked at all; a dynasm regression test is named "force_fn must NOT be called with jitframe pointer … expected a PyFrame pointer". #777 independently reached the same conclusion and stamped assembler_call_helper resumes the callee frame the rewritten CALL_ASSEMBLER passed as arg 0.

Upstream never rebuilds: ll_portal_runner(*args) forwards greens+reds verbatim (warmspot.py:941-959), the frame red is a live heap PyFrame(W_Root) (interp_jit.py:66-92), and forcing a virtualizable writes back into the existing object (resume.py:1399-1408, virtualizable.py:126-137). rg "flavor\s*=\s*'stack'" over rpython/+pypy/ returns 0 hits — RPython has no stack-allocated frame flavor. ll_portal_runner_shim already ran the frame directly; both now share run_frame_through_portal.

No reachable repro for this one. An lldb breakpoint on the mangled symbol (it resolves, so 0 is meaningful) counted 0 hits across 8 hand-written probes × 2 backends, all 19 bench/*.py + 31 bench/synth/*.py, and 112 corpus scripts on cranelift ([ca-shim] entering = 0 in every one). On dynasm the leg is unreachable by construction — the GC rewriter expect()s the CA token to be registered, and the same compile_loop body that registers it stamps set_ll_function_addr a few dozen lines later with no early return between. So this commit restores upstream structure on a path that would be wrong whenever it does fire, rather than fixing an observed crash.

Cleanups (commits 4-5)

An ast-grep -p 'PyFrame { $$$ }' census found exactly five PyFrame struct literals repo-wide, and 20 of 22 new_for_call* callers already wrap in FrameBox::new. The two that did not were commit 3's rebuild and PendingInlineFrame.concrete_frame — a StdAlloc by-value frame whose locals seeding and last_instr stamp were dead weight (no code reads the field; the callee's concrete state is carried by sym.concrete_locals / sym.concrete_stack and the frame vable bound to sym.frame). PyFrame::new_minimal, new_with_namespace and alloc_fixed_array_from_vec had no callers at all.

Verification

  • s4_arena_tb_recycle: held 38 mutated 0 on both backends (a control binary built without commit 2 still prints mutated 15).
  • 9 sys._getframe / f_back discriminators: 9/9 × 2 backends × {default, PYRE_FBW_MULTIFRAME=1}.
  • Recursion battery (self-recursive, 2-arg, mutual, 3-arg) and a RecursionError-inside-CALL_ASSEMBLER probe: correct on every combination.
  • check.py: 313/313 × 4 — dynasm and cranelift × default and PYRE_FBW_MULTIFRAME=1, run serially.
  • cargo test -p pyre-jit-trace -p pyre-jit --features dynasm: 644 passed.
  • No pinning leak: fib RSS 60 MB with the JIT vs 593 MB under PYRE_NO_JIT=1.
  • Perf A/B (dynasm, arms interleaved, 9 reps, min): fib_recursive 0.985, raise_catch 0.955, int_loop 0.961, inline_helper 1.002 — consistent with the measurement that only 0.4% of calls ever reached the arena. (A first A/B that ran arm A's reps then arm B's showed a fake +20% on fib_recursive and +15% on int_loop, a pure loop that allocates no callee frame at all; that impossible second number is what exposed it as load drift.)

Re-verified end to end on the current base (cb4f6bd409, after #768 / #784 / #788 / #795):

  • check.py: 322/322 x 4 — dynasm and cranelift x default and PYRE_FBW_MULTIFRAME=1, run serially.
  • 9 discriminators + s4_arena_tb_recycle (held 38 mutated 0): 9/9 x both backends.
  • cargo test -p pyre-jit-trace -p pyre-jit --features dynasm: 645 passed.

Review follow-ups

  • InlineConcreteFrameGuard::enter inherited the enclosing callee's frame on a null seed — the slot is now set unconditionally. A nested sub-walk whose seed block bailed has no frame of its own, and publishing the outer callee's frame for the inner callee's residuals resolves one level too shallow, which is the error the guard exists to stop; a null slot makes ResidualFrameChainGuard::enter publish nothing.
  • unroot_callee_frame did not re-arm the remembered set — it does now, on the frame and its old-gen locals array. The live-frame list is what makes a minor collection scan those slots; once the frame leaves it, a young ref stored since the last minor (argument boxing, the CALL_ASSEMBLER writeback in jit_frame_set_slot_*) is reachable only through the array's own items, which a minor does not walk. Before this PR the frame was recycled at that point, so the window is new. FrameBox::new arms the same barrier at creation and the first minor consumes it.
  • run_frame_through_portal dereferenced its argument unchecked — now a debug_assert!. The suggested "return NULL for a tagged/absent slot" guard was not taken: the portal's result is a Ref whose NULL spelling means exception stored, so returning NULL for a non-frame manufactures an exception-less error instead of a clean deopt. jit_drop_callee_frame tolerates a tagged word only because it does nothing but unroot.
  • mark_as_escaped inside the escaped_published predicate — left as is. escaped() is set lazily, when Python reads tb.tb_frame; relying on ResidualFrameChainGuard::drop alone would drop the mark on a frame whose escape has not been observed yet.

Unrelated rider

display: skip the subclass-dunder probe for exact builtin instancesbuiltin_subclass_dunder_obj ran two uncached MRO walks plus a descriptor call on every str/repr/format of an exact int/float/bool/str/list/bytearray, although only a subclass can redirect the dunder. long is excluded because its __str__/__repr__ also enforce sys.set_int_max_str_digits (longobject.py descr_repr) and that check sits in the descriptor rather than in the conversion — synth/int_max_str_digits caught the first, broader version of this change on all four arms. synth/comprehension_object_append_hot goes 27.8/32.9/27.5/51.7x to 22.6/25.0/22.1/25.7x; its gate goes 40 to 32.

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability of JIT-compiled calls involving nested or inlined functions.
    • Corrected frame-chain tracking during residual execution, helping stack information and frame state remain accurate.
    • Improved garbage-collection handling for active JIT callee frames.
    • Streamlined forced JIT execution to preserve the correct interpreter frame context.
  • Documentation

    • Clarified internal frame-layout, tracing, and execution behavior documentation.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

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

Next review available in: 41 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

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: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1059353a-d8e7-4a94-b1ef-5f6a8174e350

📥 Commits

Reviewing files that changed from the base of the PR and between 54c7374 and 8ebf464.

📒 Files selected for processing (18)
  • majit/majit-backend-cranelift/src/compiler.rs
  • pyre/bench/synth/comprehension_object_append_hot.py
  • pyre/pyre-interpreter/src/display.rs
  • pyre/pyre-interpreter/src/executioncontext.rs
  • pyre/pyre-interpreter/src/importing.rs
  • pyre/pyre-interpreter/src/module/thread/mod.rs
  • pyre/pyre-interpreter/src/module/time/interp_time.rs
  • pyre/pyre-interpreter/src/objspace/std/mapdict.rs
  • pyre/pyre-interpreter/src/pyframe.rs
  • pyre/pyre-jit-trace/src/helpers.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
  • pyre/pyre-jit-trace/src/state.rs
  • pyre/pyre-jit-trace/src/trace.rs
  • pyre/pyre-jit/src/call_jit.rs
  • pyre/pyre-jit/src/eval.rs
  • pyre/pyre-object/src/listobject.rs
  • pyre/pyrex/src/lib.rs

Walkthrough

The PR replaces arena-based JIT callee-frame allocation and rooting with live-frame tracking, routes forced execution through existing PyFrame objects, and updates inline residual execution to maintain the correct concrete frame chain. Unused constructors and related documentation are removed or revised.

Changes

JIT frame and inline execution changes

Layer / File(s) Summary
Callee-frame layout, allocation, and rooting
pyre/pyre-jit/src/call_jit.rs, majit/majit-backend-cranelift/src/compiler.rs
Backend layout registration, live callee-frame rooting, allocation/drop paths, portal execution, and related validation are migrated from arena descriptors and reuse.
Symbolic inline callee reconstruction
pyre/pyre-jit-trace/src/state.rs, pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs, pyre/pyre-jit-trace/src/trace.rs
Pending inline state no longer stores a reconstructed concrete frame, while inline calls retain and publish the concrete callee pointer for sub-walks.
Inline residual frame-chain guards
pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
Residual execution temporarily rewires and restores interpreter frame-chain state, and escape flushing accounts for published inline frames.
Interpreter constructors and supporting documentation
pyre/pyre-interpreter/src/pyframe.rs, pyre/pyre-jit-trace/src/helpers.rs, pyre/pyre-jit/src/eval.rs
Unused frame and fixed-array constructors are removed, and comments describe the updated frame and tracing behavior.

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

Sequence Diagram(s)

sequenceDiagram
  participant JITCall
  participant InlineCall
  participant FrameGuards
  participant ResidualExecution
  participant PyFrame
  JITCall->>PyFrame: allocate and publish callee frame
  InlineCall->>FrameGuards: enter concrete callee frame
  FrameGuards->>ResidualExecution: execute residual with rewired frame chain
  ResidualExecution-->>FrameGuards: restore frame state
  JITCall->>PyFrame: run forced callee through portal
Loading

Possibly related PRs

Suggested reviewers: lifthrasiir

Poem

I’m a rabbit guarding frames tonight,
Through inline paths and portal light.
Arena leaves, live roots appear,
Residual calls hop safely near.
With every guard restored just right,
The JIT burrows on—goodnight!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: JIT-created interpreter frames become GC-owned and the FrameArena/portal frame rebuild are retired.
✨ Finishing Touches
🧪 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 Jul 25, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 8ebf464).
Updated: 2026-07-26T06:44:18.220Z

Files in the reviewed diff
majit/majit-backend-cranelift/src/compiler.rs
pyre/bench/synth/comprehension_object_append_hot.py
pyre/pyre-interpreter/src/display.rs
pyre/pyre-interpreter/src/executioncontext.rs
pyre/pyre-interpreter/src/importing.rs
pyre/pyre-interpreter/src/module/thread/mod.rs
pyre/pyre-interpreter/src/module/time/interp_time.rs
pyre/pyre-interpreter/src/objspace/std/mapdict.rs
pyre/pyre-interpreter/src/pyframe.rs
pyre/pyre-jit-trace/src/helpers.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
pyre/pyre-jit-trace/src/state.rs
pyre/pyre-jit-trace/src/trace.rs
pyre/pyre-jit/src/call_jit.rs
pyre/pyre-jit/src/eval.rs
pyre/pyre-object/src/listobject.rs
pyre/pyrex/src/lib.rs

1. Regressions to PyPy parity introduced by this patch

None.

2. Other mismatches introduced by this patch

  • pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs:369 ↔ pypy/interpreter/executioncontext.py:85: the new guard publishes only ExecutionContext.topframeref; it does not install the concrete callee as pyre’s CURRENT_FRAME. Pyre maintains that extra current-frame root/identity separately, so residual code using a CURRENT_FRAME-based facility can still observe the caller while sys._getframe() observes the callee. The publication must update and restore both representations atomically.

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

  • pyre/pyre-interpreter/src/module/thread/mod.rs:813 ↔ pypy/module/thread/os_local.py:25: W_Local has no equivalent of PyPy’s initargs. On a new thread, current_dict() merely creates/registers a dictionary (mod.rs:828-833), while PyPy’s create_new_dict() invokes the subclass initializer again with the original construction arguments (os_local.py:47-63). The patch improves initial argument rejection but does not address this existing per-thread initialization mismatch.

  • pyre/pyre-interpreter/src/objspace/std/mapdict.rs:2947 ↔ pypy/objspace/std/mapdict.py:869: INSTANCE_DICT and WEAKREF_TABLE remain raw-address HashMap side tables. PyPy stores both dictionary and weakref-special state in each object’s map/storage through SPECIAL reads/writes (mapdict.py:869-902, :786-803); side tables have different ownership, identity, and lifetime semantics.

4. Structural adaptations

  • pyre/pyre-interpreter/src/module/time/interp_time.rs:195 ↔ pypy/module/time/interp_time.py:683: scoping before_external_block() to each blocking sleep call is a necessary free-threaded-GC adaptation of PyPy’s GIL-releasing nanosleep loop; signal handling remains inside the running-mutator census.

  • pyre/pyre-interpreter/src/objspace/std/mapdict.rs:57 ↔ pypy/objspace/std/mapdict.py:55: striped reentrant locks around mapdict transitions are a necessary replacement for PyPy’s GIL serialization. The patch preserves map/storage ownership rather than changing the mapdict model.

  • pyre/pyre-jit/src/call_jit.rs:303 ↔ pypy/interpreter/baseobjspace.py:799: replacing the off-GC frame arena with GC-managed FrameBox allocations and an explicit live-callee root walker is a Rust/GC implementation adaptation. It better preserves PyPy’s ordinary per-call PyFrame lifetime and identity model.

@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: 54c7374ba6

ℹ️ 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 +321 to +323
if !frame.is_null() {
INLINE_CONCRETE_FRAME.with(|slot| slot.set(frame));
}

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 Clear the parent when the callee frame is unavailable

When a nested strict callee is not seeded (for example, once inline_depth reaches fbw_max_multiframe_depth() or a seed precondition bails out), concrete_callee_frame remains null, but enter(null) leaves INLINE_CONCRETE_FRAME pointing to the outer callee. Every residual in the nested sub-walk then publishes that outer frame through ResidualFrameChainGuard, so frame-sensitive operations such as sys._getframe() or traceback creation report the parent's pycode/globals/locals as the current callee. The null case must not inherit another frame; ideally this path should materialize and select the nested callee's own concrete frame.

AGENTS.md reference: AGENTS.md:L24-L41

Useful? React with 👍 / 👎.

@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: 3

Caution

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

⚠️ Outside diff range comments (1)
pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs (1)

2211-2220: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Callee frames are now GC-owned, but several raw *mut PyFrame copies assume they never relocate. Replacing pinned arena slots with FrameBox::new allocations makes reachability the only guarantee the collector provides; address stability is assumed but nowhere asserted, while compiled code (register / jitframe slot), callee_concrete_r, and the new sub-walk pointer all hold copies the collector cannot fix up.

  • pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs#L2211-L2220: concrete_callee_frame is retained past drop(frame) and dereferenced/mutated by ResidualFrameChainGuard for the whole sub-walk; if the frame moves, the stamped frontend op is updated but this local is not. Re-read the pointer from the rooted channel (the op's concrete) at guard-entry time, or document/enforce the pinning.
  • pyre/pyre-jit/src/call_jit.rs#L382-L389: the walker's comment asserts "non-moving stable allocation, so the visitor never rewrites the slot" — turn that into a debug_assert that the visited slot is unchanged, so a future collector change fails loudly instead of silently stranding compiled code's copy.
🤖 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 `@pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs` around lines 2211 -
2220, The callee-frame raw pointer must not outlive an unverified GC allocation.
In pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs:2211-2220, update the
ResidualFrameChainGuard sub-walk setup to re-read concrete_callee_frame from the
rooted frontend op at guard entry, or explicitly enforce pinning before
retaining it past drop(frame); preserve the existing reachability root. In
pyre/pyre-jit/src/call_jit.rs:382-389, add a debug assertion that the visited
frame slot remains unchanged after GC visitation, matching the stable-allocation
assumption.
🤖 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-jit-trace/src/jitcode_dispatch/inline_call.rs`:
- Around line 2067-2071: Make the nested strict-inline fallback explicit for
concrete_callee_frame when the seed block cannot materialize a frame, including
raw.is_null(), nonzero ncells, PopJumpIfNone, unresolved frame registers, or
null snapshot_sym. Update the InlineConcreteFrameGuard::enter flow or its
surrounding handling so a null value does not implicitly retain the outer callee
frame; publish an intentional no-frame state, and document the resulting
shallow-resolution behavior.

In `@pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs`:
- Around line 443-456: Update the escape-query logic around ACTIVE_FRAME_ESCAPE
and PUBLISHED_INLINE_FRAME so the initial published-frame check only computes
whether the frame matches. Move or remove the f_back mark_as_escaped
propagation, ensuring it occurs only when the active escape branch is actually
taken or solely through ResidualFrameChainGuard::drop, with no side effects when
the query returns false.

In `@pyre/pyre-jit/src/call_jit.rs`:
- Around line 663-665: Update run_frame_through_portal to validate frame_ptr
before dereferencing it, matching the sentinel and null checks used by
jit_drop_callee_frame. Return the existing clean deoptimization/failure result
for invalid values, while preserving the current portal_runner path for valid
PyFrame pointers so both jit_force_callee_frame and ll_portal_runner_shim are
protected.

---

Outside diff comments:
In `@pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs`:
- Around line 2211-2220: The callee-frame raw pointer must not outlive an
unverified GC allocation. In
pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs:2211-2220, update the
ResidualFrameChainGuard sub-walk setup to re-read concrete_callee_frame from the
rooted frontend op at guard entry, or explicitly enforce pinning before
retaining it past drop(frame); preserve the existing reachability root. In
pyre/pyre-jit/src/call_jit.rs:382-389, add a debug assertion that the visited
frame slot remains unchanged after GC visitation, matching the stable-allocation
assumption.
🪄 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: 79a74c70-dbef-4d35-b18a-dcde7cf68923

📥 Commits

Reviewing files that changed from the base of the PR and between 0b70650 and 54c7374.

📒 Files selected for processing (9)
  • majit/majit-backend-cranelift/src/compiler.rs
  • pyre/pyre-interpreter/src/pyframe.rs
  • pyre/pyre-jit-trace/src/helpers.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
  • pyre/pyre-jit-trace/src/state.rs
  • pyre/pyre-jit-trace/src/trace.rs
  • pyre/pyre-jit/src/call_jit.rs
  • pyre/pyre-jit/src/eval.rs

Comment thread pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs
Comment on lines +443 to +456
let escaped_published = PUBLISHED_INLINE_FRAME.with(|slot| {
let published = slot.get();
let matched = !published.is_null() && std::ptr::eq(published, frame);
if matched {
let f_back = unsafe { (*published).get_f_back() };
if !f_back.is_null() {
unsafe { (*f_back).mark_as_escaped() };
}
}
matched
});
ACTIVE_FRAME_ESCAPE.with(|slot| {
if let Some((expected, py_pc)) = slot.get()
&& expected == frame as usize
&& (expected == frame as usize || escaped_published)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the mark_as_escaped side effect out of the predicate.

escaped_published is evaluated before ACTIVE_FRAME_ESCAPE is even consulted, so the caller-escape propagation fires on any query for the published frame — including when there is no active escape guard and the function returns false having done nothing else. It also duplicates the identical propagation in ResidualFrameChainGuard::drop (Lines 391-396). Computing only matched here and doing the marking inside the taken branch (or relying solely on the guard's drop) keeps the query side-effect-free and the propagation in one place.

🤖 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 `@pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs` around lines 443 -
456, Update the escape-query logic around ACTIVE_FRAME_ESCAPE and
PUBLISHED_INLINE_FRAME so the initial published-frame check only computes
whether the frame matches. Move or remove the f_back mark_as_escaped
propagation, ensuring it occurs only when the active escape branch is actually
taken or solely through ResidualFrameChainGuard::drop, with no side effects when
the query returns false.

Comment on lines +663 to +665
fn run_frame_through_portal(frame_ptr: i64) -> i64 {
let frame = unsafe { &mut *(frame_ptr as *mut PyFrame) };
let result = crate::eval::portal_runner(frame);

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 | 🟠 Major | ⚡ Quick win

run_frame_through_portal dereferences frame_ptr unchecked while jit_drop_callee_frame guards the same value.

jit_drop_callee_frame (Line 3882) treats frame_ptr & 1 != 0 as a non-frame sentinel and bails. Both helpers read the callee-frame slot the rewritten CALL_ASSEMBLER passes as arg 0, so the force path can observe the same sentinel — and a NULL slot after a failed callee-frame creation — yet here it is cast straight to &mut PyFrame and handed to portal_runner. That is a segfault inside the portal rather than a clean deopt. Both callers (jit_force_callee_frame, ll_portal_runner_shim) inherit the gap, so guard it at this root.

🛡️ Proposed guard mirroring the drop path
 fn run_frame_through_portal(frame_ptr: i64) -> i64 {
+    // Mirror `jit_drop_callee_frame`: a tagged/absent callee-frame slot is
+    // not a `PyFrame` and must not be resumed.
+    if frame_ptr == 0 || frame_ptr & 1 != 0 {
+        return pyre_object::PY_NULL as i64;
+    }
     let frame = unsafe { &mut *(frame_ptr as *mut PyFrame) };
     let result = crate::eval::portal_runner(frame);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn run_frame_through_portal(frame_ptr: i64) -> i64 {
let frame = unsafe { &mut *(frame_ptr as *mut PyFrame) };
let result = crate::eval::portal_runner(frame);
fn run_frame_through_portal(frame_ptr: i64) -> i64 {
// Mirror `jit_drop_callee_frame`: a tagged/absent callee-frame slot is
// not a `PyFrame` and must not be resumed.
if frame_ptr == 0 || frame_ptr & 1 != 0 {
return pyre_object::PY_NULL as i64;
}
let frame = unsafe { &mut *(frame_ptr as *mut PyFrame) };
let result = crate::eval::portal_runner(frame);
🤖 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 `@pyre/pyre-jit/src/call_jit.rs` around lines 663 - 665, Update
run_frame_through_portal to validate frame_ptr before dereferencing it, matching
the sentinel and null checks used by jit_drop_callee_frame. Return the existing
clean deoptimization/failure result for invalid values, while preserving the
current portal_runner path for valid PyFrame pointers so both
jit_force_callee_frame and ll_portal_runner_shim are protected.

@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: 02443b6a09

ℹ️ 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".

// Calls return in LIFO order, so the match is normally the last
// entry.
if let Some(pos) = frames.iter().rposition(|p| *p == ptr) {
frames.swap_remove(pos);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Remember callee locals before dropping their explicit root

When a long-running JIT callee has already survived a minor collection, that collection resets its old-generation locals array's remembered-set state; a later jit_frame_set_slot_{ref,int,float} write can then install a young object without a write barrier. Removing the frame from LIVE_CALLEE_FRAMES here means that, if the frame escaped through a traceback or frame reference before returning, the next minor collection reaches the old frame but does not rescan its locals array, leaving f_locals with an evacuated pointer and risking corruption or a crash. Re-remember the frame/locals array before unrooting it, or barrier each slot write.

AGENTS.md reference: AGENTS.md:L24-L30

Useful? React with 👍 / 👎.

…l calls

An inline sub-walk executes the callee's residual calls concretely, but
nothing published the callee frame on the interpreter chain, so a residual
that reads the chain (sys._getframe, a traceback) resolved one level too
shallow.

Bracket each such residual with executioncontext.py:85 enter / :91-109
leave. The leave half propagates the escape: a frame handed to application
code keeps a reference to its caller, so the caller is forced and marked
escaped. Without that propagation the escape is recorded only on a frame
the walk owns privately, and the walk stops reporting it as an escape of
the traced virtualizable.

flush_active_frame_escape therefore also matches the frame a live
ResidualFrameChainGuard has published, keying the flush on the traced
virtualizable whose resume pc the residual already latched. For that
redirected match the escape is reported only once the flush commits a
resume pc; forcing without one raises an escape the walk can answer only
by replaying from entry, double-applying the residual's body effects.

The multi-frame CRN arm resets valuestackdepth to stack_base and declines
when the resume pc's cached stack depth is non-zero, so a resumed frame
does not leak a stack slot per iteration.

Assisted-by: Claude
…ena slots

Delete FrameArena and its slot-recycling path. The four callee-frame
creation helpers now allocate through FrameBox::new with an OldGenGc
locals array — the same non-moving PYFRAME_GC_TYPE_ID allocation the
interpreter uses for every other frame (baseobjspace.py:799-801
createframe, pyframe.py:52 class PyFrame(W_Root)) — and
jit_drop_callee_frame only drops the JIT's root on the frame instead of
returning the block to a free list or freeing it.

A frame the program retains past the call (a traceback node, an f_back
chain) previously kept pointing at a slot that the next call at the same
depth overwrote in place, including drop_in_place of the old locals
array.

The extra root walker now also marks the frame object itself: while
compiled code runs, the live-frame list is the frame's only root.

Removed with the arena: the ARENA_BUF_BASE / ARENA_TOP /
ARENA_INITIALIZED statics (no readers), GcFrameSlot, GcPyFrame,
heap_alloc_frame / heap_free_frame and reset_reused_call_frame.
arena_jitframe_descrs / arena_global_info describe the JitFrame layout,
not the frame arena, and are renamed to jitframe_layout_descrs /
jitframe_layout_info.

Assisted-by: Claude
`jit_force_callee_frame` read pycode/w_globals/execution_context off the
incoming pointer by raw offset and built a fresh `PyFrame` with an empty
argument slice, then ran that. The rebuilt frame was a Rust stack local,
so `portal_runner` published a C-stack address as `CURRENT_FRAME` and
`ec.topframeref`; every parameter slot was `PY_NULL` and `last_instr`
was reset to -1.

The incoming pointer is the callee `PyFrame` that
`emit_new_pyframe_inline_with_params` builds (`NewWithVtable` +
`SetfieldGc`), which the GC rewriter stores into the callee jitframe's
first slot. `ll_portal_runner` forwards its reds unchanged
(warmspot.py:953-954), so run that frame instead of rebuilding one.
`ll_portal_runner_shim` already did; both now call
`run_frame_through_portal`.

Assisted-by: Claude
`PyFrame::new_minimal` and `PyFrame::new_with_namespace` return a
`PyFrame` by value and have no callers. `alloc_fixed_array_from_vec` was
used only by `new_minimal`.

Assisted-by: Claude
`assemble_bridge_inline_pending` built a `StdAlloc` `PyFrame` by value,
seeded its locals and operand stack from the recipe and set its
`last_instr`. No code reads the field: the callee's concrete state is
carried by `sym.concrete_locals` / `sym.concrete_stack` and by the frame
vable that `setup_reconstructed_callee_frame` binds to `sym.frame`.

Assisted-by: Claude
`instance_lock_for(obj).lock()` blocked while the thread was still counted
as a running mutator. The stripe owner can allocate under the stripe and
request a collection, which then waits for the blocked thread while that
thread waits for the stripe. Route every acquire through `lock_stripe`,
which try-locks first and leaves the census around a contended wait, as
`w_list_lock` and `w_dict_lock` already do.

`instance_node_dict_{length,clear,keys,values,items}` read and rebuild the
same map and storage as the guarded get/set/delete entry points but took no
stripe; guard them too. The lock is reentrant and every one of these
functions is already `dont_look_inside`.

Also drops a stale rationale above `SYS_MODULES`: there is no GIL to
serialize semantic access.

Assisted-by: Claude
`time.sleep` held the guard across the whole EINTR retry loop, so
`checksignals_now` ran a Python signal handler while the mutator was
outside the RUNNING census. Any allocation or second blocking call in that
handler then trips a running-mutator assertion.

`nanosleep` is declared `releasegil=True` (interp_time.py:504-506), so only
the syscall runs outside; `checksignals()` runs with the GIL re-acquired
(interp_time.py:707). Scope the guard to each blocking call to match.

Assisted-by: Claude
`_ThreadHandle.join(float('inf'))` reached `Duration::from_secs_f64`, which
aborts the process on a non-finite or out-of-range value. `parse_acquire_args`
(os_lock.py:33-39) raises `OverflowError("timeout value is too large")`; the
handle's own timeout path now does the same, for the int arm too.

`stack_size()` read a non-integer argument's payload word as an integer.
`@unwrap_spec(size=int)` (os_thread.py:216) unwraps through `space.int_w`,
which rejects it.

`_local.__new__` allocated the instance and registered it in the execution
context before rejecting construction arguments; os_local.py:81 rejects them
before `allocate_instance`, so a refused construction never reaches
`_register_in_ec`. The check also applied only to the exact type, while
os_local.py:75-85 rejects arguments for every subtype that inherits
`object.__init__`.

`clone_for_thread` carried `coroutine_origin_tracking_depth` into the new
thread; a fresh ExecutionContext starts at 0 (executioncontext.py:53).

Assisted-by: Claude
`w_list_clear` drops the object items, replaces every typed backing array
and resets the strategy, but took no stripe, so it raced with the guarded
append/getitem/setitem/len entry points.

Assisted-by: Claude
`run_module` and `run_source` called `finalize_runtime` only on the success
path and, through `finalize_system_exit`, on SystemExit. Any other uncaught
exception exited with status 1 without running threading shutdown, atexit
handlers or module teardown. targetpypystandalone.py:88 runs
`space.finish()` from a `finally`.

The exception is printed before finalizing: `finalize_runtime` collects, and
`PyError`'s raw object fields are not GC-visible.

Assisted-by: Claude
…rame at unroot

InlineConcreteFrameGuard::enter kept the enclosing callee's frame when the
nested sub-walk had none of its own — a seed block that bailed. The inner
callee's residuals then published that outer frame, resolving the chain one
level too shallow, which is the error the guard exists to stop. Set the slot
unconditionally; a null slot makes ResidualFrameChainGuard::enter publish
nothing.

unroot_callee_frame re-arms the remembered set on the frame and its old-gen
locals array. The live-frame list is what makes a minor collection scan those
slots; once the frame leaves it, a young ref stored since the last minor is
reachable only through the array's own items, which a minor does not walk.
A frame the program retained now outlives the call, so the window is real.

run_frame_through_portal asserts its argument is an untagged frame pointer:
jit_drop_callee_frame tolerates a tagged word on the same slot because it
only unroots, but the portal has nothing to run for one, and its NULL result
spells "exception stored" rather than a deopt.

Also reformat the join timeout overflow arms.

Assisted-by: Claude
builtin_subclass_dunder_obj resolves a user override for a builtin leaf, but
it ran its probe for every instance, exact ones included: two full uncached
MRO walks (lookup_where_pair walks the same name twice) plus a descriptor
call through the generic call protocol, on every str/repr/format of an int,
float, bool, str, list or bytearray.

Only a subclass can redirect the dunder — it keeps the builtin ob_type and
retags w_class — which is what is_exact_builtin_instance tests, so an exact
instance returns None and the caller runs the leaf formatter it would have
reproduced.

`long` is excluded: its __str__/__repr__ also enforce
sys.set_int_max_str_digits (longobject.py descr_repr), and that check sits in
the descriptor rather than in the conversion, so an exact long keeps going
through it. A machine int cannot reach any settable limit — 19 digits against
a floor of 640.

Measured on synth/comprehension_object_append_hot, per shape, 500 x 1000:
f-string 0.674s -> 0.320s, dict 0.254s -> 0.215s. The bench's pypy ratio drops
from 27.8/32.9/27.5/51.7x to 22.6/25.0/22.1/25.7x across dynasm and cranelift
x default and PYRE_FBW_MULTIFRAME=1; its gate goes 40 -> 32.

Assisted-by: Claude
@youknowone

Copy link
Copy Markdown
Owner Author

Applied the review findings on 5c6acce6bd (rebased onto cb4f6bd409).

InlineConcreteFrameGuard::enter — null callee frame inherited the parent (codex P2 / coderabbit): fixed. The slot is set unconditionally; a null slot makes ResidualFrameChainGuard::enter publish nothing rather than the enclosing callee's frame.

unroot_callee_frame — remembered set not re-armed (codex P1): fixed. The live-frame list is what makes a minor collection scan the frame's slots, so leaving it is exactly the moment the old-gen locals array has to join the remembered set. FrameBox::new arms the same barrier at creation (try_gc_alloc_stable_raw + try_gc_write_barrier) and the first minor consumes it; before this PR the frame was recycled at unroot, so the window only exists now.

run_frame_through_portal unchecked deref (coderabbit major): guarded with a debug_assert! rather than the proposed early return PY_NULL. The portal's result is a Ref whose NULL spelling means exception stored, so returning NULL for a tagged/absent slot manufactures an exception-less error instead of a clean deopt. jit_drop_callee_frame tolerates a tagged word only because it does nothing but unroot — there is nothing sensible to run for one.

mark_as_escaped inside the escaped_published predicate (coderabbit nitpick): not changed. escaped() is set lazily, when Python reads tb.tb_frame; moving the propagation into the taken branch or relying solely on ResidualFrameChainGuard::drop would drop the mark for a frame whose escape has not been observed yet.

Frames relocating under the collector (coderabbit, outside-diff): FrameBox::new allocates through try_gc_alloc_stable_raw — a non-moving old-gen block — so the raw *mut PyFrame copies in compiled code, callee_concrete_r and the sub-walk local stay valid. That is what the walker's comment asserts; no change made.

Verification on the current base: check.py 322/322 x 4 (dynasm/cranelift x default and PYRE_FBW_MULTIFRAME=1, serial), 9/9 getframe discriminators x both backends with s4_arena_tb_recycle at held 38 mutated 0, cargo test -p pyre-jit-trace -p pyre-jit --features dynasm 645 passed, cargo fmt --all --check clean.

commented by Claude

@youknowone
youknowone merged commit 98c33af into main Jul 26, 2026
7 of 8 checks passed
@youknowone
youknowone deleted the ec-wiring branch July 26, 2026 06:40

@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

https://github.com/youknowone/pyre/blob/8ebf4642363626be2ffdf0e425f2a1b5c3e51c34/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs#L2506-L2508
P1 Badge Synchronize the published callee frame before residuals

When a seeded inline callee modifies a local before a frame-sensitive residual such as sys._getframe().f_locals, this guard publishes concrete_callee_frame, but that object was populated only with entry arguments at lines 2207-2215; subsequent setarrayitem_vable writes update the symbolic/concrete shadow in vable_ops.rs:591-600, not this frame object. The residual therefore observes stale entry-time locals, and its traceback can likewise use stale frame state. Synchronize the callee shadow into this object before publishing it, or make the object the callee's actual red frame throughout the walk.

AGENTS.md reference: AGENTS.md:L24-L30


https://github.com/youknowone/pyre/blob/8ebf4642363626be2ffdf0e425f2a1b5c3e51c34/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs#L2506-L2508
P2 Badge Materialize a frame for every unseeded inline callee

When a nested strict callee exceeds fbw_max_multiframe_depth() or its seed block bails, concrete_callee_frame remains null and this call deliberately installs that null; ResidualFrameChainGuard::enter then returns at residual_call.rs:356-358, leaving the caller as the visible top frame. Consequently sys._getframe(), traceback creation, and other frame-chain reads still report the wrong frame for that callee. The fresh evidence beyond the earlier comment is the new unconditional null installation plus the explicit no-publication branch; allocate and thread the nested callee's own frame instead.

AGENTS.md reference: AGENTS.md:L32-L41

ℹ️ 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".

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