Skip to content

jit(fbw): point the multi-frame blackhole adopt at the live frame and name its flip blocker; root the grabbed guard exception - #830

Merged
youknowone merged 8 commits into
mainfrom
jitcode
Jul 27, 2026
Merged

jit(fbw): point the multi-frame blackhole adopt at the live frame and name its flip blocker; root the grabbed guard exception#830
youknowone merged 8 commits into
mainfrom
jitcode

Conversation

@youknowone

@youknowone youknowone commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Two independent clusters, both landing on try_adopt_multi_frame_blackhole and
the guard-exception handoff. Every executable change is either behind
PYRE_FBW_MULTIFRAME (default off) or a GC root registration.

1. PYRE_FBW_MULTIFRAME multi-frame blackhole adopt (5 commits)

The adopt declined every chain, including well-formed ones. per_frame[0]
is recovered from the trace's frame register, whose root vable identity
seed_virtualizable_boxes bakes against live_vable_frame_addr, while the
comparison operand was cf_addr — the snapshot_for_tracing copy. Both
production entries set the live field from a real &PyFrame, so the two
operands were never equal. The prior comment attributed the decline to a chain
rooted at an intermediate frame and named the jit.virtual_ref emit as the
prerequisite; measurement refuted that.

  • Threads live_root_addr in and uses it for the two uses that ask an identity
    question — the gate and the root f_backref operand whose ptr::eq skip has
    to fire for frames[0]. The other uses keep the snapshot.
  • Folds the live frame into the snapshot before the terminal arms. Frame 0's
    blackhole level runs against per_frame[0] because
    convert_and_run_from_pyjitpl overrides each level's virtualizable_ptr, so
    its setfield_vable stores land on the live frame while the portal epilogue
    copies the snapshot's whole locals array back over it. Hoisted above the
    match: every adopted arm sets WALK_END_FLUSH_COMMITTED, and for the
    exception terminal the traceback keeps the root frame reachable, so a stale
    copy is observable through tb_frame.f_locals long after the walk.
  • Adds a frame-identity collapse guard — every level must be a distinct frame
    and only frames[0] may be the walked frame. No producer is known; the
    failure mode is silent, so it declines rather than relying on the absence.
  • Five new fixtures. Multi-frame adopt coverage across the corpus goes from 0
    to 20, plus 10 pinned declines.

The flip is still blocked, and the blocker is now named. The walker executes
residuals concretely while an inline push never runs the interpreter's call
sequence, so ec.topframeref still names the CALLER while an inlined callee
body runs. A sys._getframe that is itself the escaping residual therefore read
the wrong frame at walk time, and adopting commits that answer where legacy
escape/replay discards it:

_gf().f_code.co_name   -> "main",     not "leaf"
_gf(1).f_code.co_name  -> "<module>", not "main"
_gf(1).f_locals["k"]   -> KeyError

One wrong iteration per adopt, 5 adopts and 5 wrong in each part of
synth/getframe_while_escaping_read_frame_identity, which is the acceptance
test: it passes today with the gate off and fails loudly if the gate is flipped
first. Closing it needs the inlined-call push to publish the callee frame on the
execution context — the open walker_ec_enter / walker_ec_leave work (#796).

2. Root the grabbed guard exception across the handoff (2 commits)

grab_exc_value (llmodel.py:240) reads jf_guard_exc off the deadframe and
drops the jitframe, which held the collector's only reference to that exception
(jitframe_trace). The handoff then decodes resume data and rebuilds virtuals
through the blackhole allocator, so the value spans an allocating window as a
bare i64 that the precise collector cannot see. RPython keeps the same value
in a shadowstack-rooted local.

Adds GuardExcRoot + a GUARD_EXC_VALUE walker that marks the carrier and
forwards its young child slots, the same shape as walk_jit_exc_value /
walk_bh_last_exc_value. Parked at handle_fail,
blackhole_resume_via_rd_numb and back_edge_internal.

This is a parity fix, not a demonstrated bug fix, and the PR says so. Under
MAJIT_GC_STRESS on a gc_stress build the five benches that produce live
guard exceptions pass identically with the walker registered and suppressed —
on the residual-raise path the same exception is still parked in
BH_LAST_EXC_VALUE, which is already rooted.

A handle_fail census over the 339-file corpus counts 732,660 guard failures,
34,790 carrying a live exception into the window, 170 of them on the bridge
route. That retires bridge_guard_exc from the PYRE_CARRIER_EXC_RESUME
pre-flip list: the same pointer drives the default blackhole resume, so the gate
never bounded the exposure.

The second commit corrects the ledger's description of that gate.
seed_bridge_standing_exception_from_current is ungated and already mirrors
upstream's assign-or-clear branch; what diverges is the source
_prepare_exception_resumption takes cpu.grab_exc_value(deadframe), pyre
takes sym.current_exc_value falling back to get_current_exception(), the
sys.exc_info() mirror. The gate is a back-channel into that function, and its
is_null conjunct exists to avoid clobbering a live sys.exc_info value — so
the "add the missing unconditional assign" reading would have corrupted
PUSH_EXC_INFO / POP_EXCEPT rather than restored parity.

Rebase note

Rebased onto origin/main 948340d. Three conflicts, all inside
try_adopt_multi_frame_blackhole, where #798 and this branch rewrote the same
comment from the same original. Resolved as blends: #798's mfdbg!
decline-naming is kept in full and extended to the two new declines, the
identity-gate prose takes this branch's corrected attribution, and the decline
message no longer prints the virtual_ref cause the same commit refutes. #798's
removal of the resume_py_pc == 0 check survives untouched.

Gates

Measured on the rebased tree, after re-extracting LLBC.

run result
check.py --backend dynasm 335/336
check.py --backend dynasm, PYRE_FBW_MULTIFRAME=1 336/336
check.py --backend dynasm, PYRE_CARRIER_EXC_RESUME=1 336/336
check.py --backend cranelift 336/336
cargo test -p majit-metainterp -p pyre-jit-trace -p pyre-jit exit 0

The one difference is nested_loop at 2.0x against its 2x perf gate, measured
while eight to eleven cargo builds from sibling worktrees were running.
Interleaved against pypy with check.py's own startup subtraction, seven rounds
give 1.54x / 1.62x / 1.64x / 1.65x / 1.66x / 1.68x / 1.69x, and the bench passes
in both gated runs above.

authored by Claude

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability when handling exceptions during JIT and blackhole resume operations, preventing exception values from being lost during garbage collection.
    • Corrected frame-linking and frame identity handling for multi-frame execution paths.
    • Preserved caller and captured-frame information across inlined calls and escaping frame scenarios.
  • Tests

    • Added benchmarks covering frame capture, frame identity, caller-local preservation, and multi-frame execution behavior.
  • Documentation

    • Updated gate-triage guidance with validation results, known limitations, and current rollout status.

`try_adopt_multi_frame_blackhole` declines when the recovered blackhole
chain's root does not equal `cf_addr`. Its comment attributed that to a
chain rooted at an intermediate frame, and named the `jit.virtual_ref`
emit at the inline push as the prerequisite for closing it.

Measured with the in-tree `[s2-gate]` / `[s2-build-decline]` diagnostics
under `PYRE_FBW_MULTIFRAME=1 PYRE_FBW_DEBUG_ABORT=1`, on a `while` loop
calling an inlined callee that calls a zero-argument `sys._getframe`: the
latch site is reached, `build_multi_frame_miframe` returns an image at
depth 2, and all five decline events print `per_frame[0]` equal to the
live frame address and different from `cf_addr`.

`per_frame[0]` is read from the trace's frame register, which
`seed_virtualizable_boxes` bakes against `live_vable_frame_addr`
(`state.rs`); that field is set from a real `&PyFrame` at both production
entries (`call_jit.rs`, `eval.rs`) and falls back to the snapshot address
only on the unit-test path. `cf_addr` is the `snapshot_for_tracing` copy.
The two are different representations of the same frame, so for this
shape the comparison declines unconditionally and no `VIRTUAL_REF` emit
is involved.

Rewrites the comment to state that, and records the measurement in
gate-triage §1d in place of the claim that building such a benchmark was
the prerequisite for the materialization and per-frame-vable items: the
build side already succeeds, so those items sit below this comparison.

Comment and documentation only; no behavior change.

Assisted-by: Claude
…rame

`try_adopt_multi_frame_blackhole` compared the recovered chain's root against
`cf_addr`, the `snapshot_for_tracing` copy, while `per_frame[0]` is recovered
from the trace's frame register, whose root vable identity
`seed_virtualizable_boxes` bakes against `live_vable_frame_addr`. Both
production entries set that field from a real `&PyFrame`, so the two operands
were never equal and the adopt declined every chain, including ones rooted at
the walked frame.

Thread the live root address in and use it for the two uses that ask an
identity question: the comparison, and the root `f_backref` operand whose
`ptr::eq` skip has to fire for `frames[0]`. The other uses keep the snapshot.
`apply_blackhole_crn` writes the image the portal epilogue propagates, and
`concrete_nlocals` / the `execution_context` read are address-agnostic. The
vable root and `stack_base` passed to `drive_multi_frame_blackhole` are dead
on this path, because `convert_and_run_from_pyjitpl` overrides both per level
from `per_frame`; noted rather than removed.

That override is also why the adopt now folds the live frame into the snapshot
before the CRN write: frame 0's level runs against `per_frame[0]`, so its
`setfield_vable` stores land on the live frame, while the epilogue copies the
snapshot's whole locals array back over it (`restore_resume_state_from`) and
would revert any of those stores the CRN write does not cover.

Adds `synth/getframe_while_inlined_callee_subwalk`, the first fixture that
reaches the vable-escape latch inside an inline sub-walk. The corpus had no
coverage: every other `getframe_*` fixture drives with `for`, and with a
FOR_ITER item in flight `fbw_abort_nested_unjournaled_residual` declines the
callee's nested residual before `execute_residual_call` runs.

All of this sits behind `PYRE_FBW_MULTIFRAME`, default off.

With the gate on, the new fixture reports 5 `BUILT multi-frame depth=2` and 5
`adopted multi-frame terminal` with zero declines, against 0 adopts before,
and prints the same result as CPython and PyPy; the synthetic corpus under the
gate is 308 passed with no correctness failure. Default gates: `pyre-jit-trace`
and `majit-metainterp` unit tests pass; `check.py --backend dynasm` 322 passed
with `nested_loop` discriminated as machine load (isolated 1.7x / 1.8x / 1.9x
against its 2x gate).

Assisted-by: Claude
…ild decline

The multi-frame blackhole path had one fixture. Adds three, each verified to
reach the vable-escape latch inside an inline sub-walk on a clean build:

- `getframe_while_captured_frame_outlives_call` keeps the callee's frame past
  the loop and reads its `f_back`, which is the only way to observe a chain
  root linked to the `snapshot_for_tracing` copy rather than the live frame:
  the snapshot is freed at walk end.
- `getframe_while_caller_locals_across_subwalk` carries two caller locals
  across every iteration. Frame 0's blackhole level writes the live frame while
  the resume-state write targets the snapshot, so a slot the resume-state write
  does not cover would revert and change the printed totals.
- `getframe_while_subwalk_decline_shapes` pins the two shapes the build
  refuses, so a decline cannot silently become a wrong answer.

Coverage across the corpus goes from 0 multi-frame adopts to 20, plus 10
pinned declines.

Both refused shapes -- an exception handler around the inlined call, and two
nested inlined levels -- share one cause, instrumented rather than inferred: a
ref color that is live at the caller's post-call coordinate holds
`ConcreteValue::Null`. Neither is the not-yet-produced result slot. Neither
involves the bridge parent-frame constructors, since every latch event reports
`not_bridge=true` and the latch requires `!is_bridge_trace`. `Null` is the
untracked sentinel and is deliberately distinct from `Ref(PY_NULL)`, an
uninitialised local, so accepting it would fabricate a parent frame; the
decline is correct and closing these shapes is the outer-locals materialization
already named in the ledger. The gate-triage text that attributed this to the
bridge constructors was wrong and is corrected.

`check.py` on the full corpus is 326/326 on dynasm and cranelift, with
`PYRE_FBW_MULTIFRAME` both off and on.

Assisted-by: Claude
With the multi-frame adopt working, an inlined callee that reads its CALLER's
frame through `sys._getframe(1)` is neither declined nor correct. The chain
runs innermost-first and each level runs against its own live frame, but an
outer level's recovered locals are still only in its blackhole registers at
that moment; nothing writes them back before the inner level re-executes.

Forcing the gate on:

    f = _gf(1); return x + f.f_locals["bias"]              -> KeyError: 'bias'
    f = _gf(1); return x + (1 if f.f_code.co_name == ...)  -> 29995, not 30000

The count is exact -- one lost iteration per multi-frame adopt, 5 adopts and 5
missing. Both are correct with the gate off, which is the default.

Adds `synth/getframe_while_outer_frame_read_from_subwalk` as the flip's
acceptance test: it passes today and fails loudly if the gate is flipped before
the outer-frame materialization lands. The gate helper's own comment named this
condition correctly; the ledger now carries the measurement instead of the
earlier reading that put the materialization downstream of the adopt.

Everything else previously thought to block the flip has been measured and does
not. The full corpus is 326/326 on dynasm and cranelift with the gate both off
and on. The blast radius is exactly `inline_subwalk = true` at a vable escape:
the latch is an `if`/`else if` whose single-frame arm requires
`!inline_subwalk`, so with the gate off that condition latches nothing and falls
to legacy escape/replay.

Bench and documentation only; no behavior change. `check.py` is 327/327 on
cranelift, and 326 passed on dynasm with `raise_catch` at 1.7x against its 1.5x
gate, which isolated three times gives 1.6x / 1.4x / 1.1x -- the recorded
boundary behavior of that bench, and untouchable by a commit that adds a
fixture and markdown.

Assisted-by: Claude
…p blocker

Three things, all found by measuring shapes the corpus does not contain.

1. The live->snapshot fold ran only in the ContinueRunningNormally arm, but
   every adopted arm sets WALK_END_FLUSH_COMMITTED, and the portal epilogue
   then copies the snapshot's whole locals array onto the live root
   (`restore_resume_state_from`). A `DoneWithThisFrame*` or
   `ExitFrameWithExceptionRef` terminal therefore had frame 0's blackhole
   stores reverted, and for the exception terminal the traceback keeps the root
   frame reachable, so the stale copy is observable through `tb_frame.f_locals`
   long after the walk. Hoisted above the match.

2. Adds a frame-identity collapse guard: every recovered level must be a
   distinct frame and only `frames[0]` may be the walked frame. A level whose
   frame register resolved to the root would make the relink write an
   `f_backref` cycle and run an inner level against the root's virtualizable.
   No producer is known; the failure would be silent, so decline instead of
   relying on the absence.

3. Replaces the acceptance fixture added in the previous commit, whose header
   named the wrong mechanism. The blocker is not outer-locals staleness. The
   walker executes residuals concretely while an inline push never runs the
   interpreter's call sequence, so `ec.topframeref` still names the CALLER
   while an inlined callee body runs, and a `sys._getframe` that is itself the
   escaping residual reads the wrong frame at walk time. Adopting commits that
   answer where legacy escape/replay discards it:

       _gf().f_code.co_name   -> "main",     not "leaf"
       _gf(1).f_code.co_name  -> "<module>", not "main"
       _gf(1).f_locals["k"]   -> KeyError

   One wrong iteration per adopt, 5 adopts and 5 wrong in each part of
   `synth/getframe_while_escaping_read_frame_identity`. A `sys._getframe`
   executed after the escape, inside the blackhole, is correct, and an
   in-blackhole read of a caller local mutated earlier in the same iteration
   matches CPython and PyPy. So the original decline comment was right that an
   inline-push `enter` is the prerequisite and wrong only about which check it
   gated; that sentence is restored alongside the corrected one.

Also recorded: with a side-effecting inlined callee in a `while` loop that
returns from inside the loop, the default path runs the side effect ~5.2k times
too often -- the known trace-abort double-run class -- while the adopt produces
the exact count.

`PYRE_FBW_MULTIFRAME` stays default-off. Unit tests pass; `check.py` is 327/327
on dynasm and on cranelift.

Assisted-by: Claude
…doff

`grab_exc_value` (llmodel.py:240) reads `jf_guard_exc` off the deadframe and
drops the jitframe, which held the collector's only reference to that exception
(`jitframe_trace`). The handoff then decodes resume data and rebuilds virtuals
through the blackhole allocator, so the value spans an allocating window as a
bare `i64` that the precise collector cannot see. RPython's `grab_exc_value`
result is a shadowstack-rooted local across the same span.

Add `GuardExcRoot`, which parks the value in a `GUARD_EXC_VALUE` thread-local,
and a frontend root walker that marks the carrier and forwards its young child
slots — the same shape as `walk_jit_exc_value` / `walk_bh_last_exc_value`. Park
at `handle_fail`, `blackhole_resume_via_rd_numb` (which also covers the
CALL_ASSEMBLER caller) and `back_edge_internal`.

A `handle_fail` census over the 339-file `bench/` + `bench/synth/` corpus counts
732,660 guard failures, of which 34,790 carry a live exception into the window —
170 of those on the bridge route, which is the `bridge_guard_exc` read itself.
gate-triage §1e records the census and retires `bridge_guard_exc` from the
`PYRE_CARRIER_EXC_RESUME` pre-flip list: the same pointer drives the default
blackhole resume, so the gate never bounded the exposure, and the seed site is
reachable rather than inert.

The walker is not load-bearing on any measured workload. Under `MAJIT_GC_STRESS`
on a `gc_stress` build, the five benches that produce live guard exceptions pass
identically with it registered and suppressed — on the residual-raise path the
same exception is still parked in `BH_LAST_EXC_VALUE`, which is already rooted.
This is a parity fix at a reachable site, not a demonstrated bug fix.

Assisted-by: Claude
…ce correctly

The row called the gap "the unconditional `execute_ll_raised` exception assign",
which is not what diverges.

`seed_bridge_standing_exception_from_current` is ungated and already mirrors
upstream's branch — it assigns `last_exc_value` / `last_exc_box` when it finds an
exception and clears all four exception slots when it does not. What differs is
the source: `_prepare_exception_resumption` takes the exception the failing guard
carried (`cpu.grab_exc_value`), while pyre takes `sym.current_exc_value` falling
back to `get_current_exception()` — the execution context's current exception,
i.e. the `sys.exc_info()` mirror.

The gate is a back-channel into that function: it writes `guard_exc` into
`current_exc_value` so the ungated code picks it up, and its `is_null` conjunct
avoids clobbering a live `sys.exc_info` value, which suppresses the injection
exactly when the EC already holds an exception. Measured with the gate forced on:
dynasm 334/334, byte-identical to the default, and the seven benches that produce
a live guard exception individually identical, despite the seed site being
entered 170 times.

Record that, plus two further deltas in the same function to settle before any
flip: it early-returns when `last_exc_box` is already set, and it sets
`class_of_last_exc_is_const = true` where the `_prepare_exception_resumption`
path reaches `execute_ll_raised` with the default `constant=False`.

Assisted-by: Claude
The corpus grew from 334 to 336 files when the branch was rebased, so the two
recorded gate measurements no longer name the corpus they were taken against.
Re-measured on the rebased tree:

  dynasm, default                    335/336
  dynasm, PYRE_FBW_MULTIFRAME=1      336/336
  dynasm, PYRE_CARRIER_EXC_RESUME=1  336/336
  cranelift, default                 336/336

The one default-run difference is `nested_loop` at 2.0x against its 2x perf
gate, measured while eight to eleven cargo builds from sibling worktrees were
running. Interleaved against pypy with the same startup subtraction check.py
uses, seven rounds give 1.54x / 1.62x / 1.64x / 1.65x / 1.66x / 1.68x / 1.69x,
and the bench passes in the two gated runs above.

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The change roots failed-guard exceptions through blackhole and bridge handoffs, updates multiframe adoption to use live root frame state, and adds synthetic frame-behavior benchmarks plus gate-triage measurements.

Changes

Blackhole handoff correctness

Layer / File(s) Summary
Guard exception rooting
majit/majit-metainterp/src/blackhole.rs, majit/majit-metainterp/src/jitdriver.rs, majit/majit-metainterp/src/trace_ctx.rs, pyre/pyre-jit/src/call_jit.rs, pyre/pyre-jit/src/eval.rs
Guard exceptions are parked in a nested TLS-backed RAII root and exposed through a registered GC root walker during resume and bridge setup.
Live-root multiframe adoption
pyre/pyre-jit-trace/src/trace.rs, pyre/gate-triage.md
Multiframe adoption validates live frame identity, uses the live root for chain linking, folds live resume state into the snapshot, and documents the measured behavior and remaining frame-identity blocker.
Synthetic frame-behavior fixtures
pyre/bench/synth/getframe_while_*.py
New scripts exercise caller-local liveness, captured frames, inlined subwalks, escaping frame identity, and decline-shaped control flows.

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

Possibly related issues

  • youknowone/pyre#811 — Covers the root-mismatch decline addressed in try_adopt_multi_frame_blackhole.
  • youknowone/pyre#724 — Covers GC rooting and multiframe blackhole handoff correctness.

Possibly related PRs

  • youknowone/pyre#759 — Introduced the bridge_guard_exc carrier flow that now uses GuardExcRoot.

Poem

A rabbit guards an exception bright,
Rooted safely through the night.
Live frames hop in proper line,
Subwalks keep their state just fine.
Benchmarks thump their little feet—
“Blackhole handoffs now complete!”

🚥 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 accurately reflects the two main changes: live-frame multi-frame blackhole adoption and rooting the grabbed guard exception.
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 jitcode

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.

@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: 08b29da1be

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

impl GuardExcRoot {
pub fn park(exc: i64) -> Self {
Self {
prev: GUARD_EXC_VALUE.with(|cell| cell.replace(exc)),

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 Keep every nested guard exception rooted

When a bridge handoff deopts while an outer handoff is still active, replace removes the outer exception from the only registered root for the entire nested handoff. Restoring prev on drop is too late if the nested resume decode allocates and triggers a collection, because the outer exception remains only in raw i64 state and can become dangling. Store all active handoff exceptions in a rooted stack rather than a single replaceable cell.

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

Useful? React with 👍 / 👎.

Comment thread pyre/pyre-jit/src/eval.rs
pyre_interpreter::eval::register_pyframe_root_walker();
majit_gc::shadow_stack::register_extra_root_walker(walk_jit_exc_value);
majit_gc::shadow_stack::register_extra_root_walker(walk_bh_last_exc_value);
majit_gc::shadow_stack::register_extra_root_walker(walk_guard_exc_value);

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 Register the guard-exception root for every mutator

In a multithreaded run, register_extra_root_walker invokes this callback on the collecting thread, so it reads that thread's GUARD_EXC_VALUE rather than the cell belonging to a stopped mutator currently handling a guard failure. If another thread starts a collection during that handoff, the parked exception is omitted from the root set and may be reclaimed; the existing pending-exception and BH_LAST_EXC_VALUE carriers solve this by also exposing their TLS cells through a per-mutator root area, which this new carrier needs as well.

AGENTS.md reference: AGENTS.md:L148-L162

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: 5

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/src/eval.rs (1)

3319-3357: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Move the guard-exception root out of TLS.

GUARD_EXC_VALUE is interpreter-owned, identity-sensitive, GC-relevant semantic state, yet GuardExcRoot::park stores it in TLS. The cited upstream model is a shadowstack-rooted local, not TLS; retain the exception on the established interpreter/GC-root owner and have the walker traverse that owner instead.

  • pyre/pyre-jit/src/eval.rs#L3319-L3357: replace the TLS root walker with traversal of the durable owner.
  • pyre/pyre-jit/src/call_jit.rs#L1905-L1909: pass or scope that owner across blackhole resume rather than parking into TLS.
  • majit/majit-metainterp/src/trace_ctx.rs#L541-L544: update the ownership contract after moving the carrier.

As per coding guidelines, “Do not use TLS for process-global, interpreter-owned, identity-sensitive, semantic, registry, cache, or GC-relevant runtime state.”

🤖 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/eval.rs` around lines 3319 - 3357, Move GUARD_EXC_VALUE
ownership out of TLS and into the established interpreter/GC-root owner,
preserving the guard exception across blackhole resume without changing its
identity semantics. In pyre/pyre-jit/src/eval.rs lines 3319-3357, replace
walk_guard_exc_value’s TLS traversal with traversal of that durable owner; in
pyre/pyre-jit/src/call_jit.rs lines 1905-1909, pass or scope the owner through
blackhole resume instead of parking it in TLS; and in
majit/majit-metainterp/src/trace_ctx.rs lines 541-544, update the ownership
contract to reflect the moved carrier.

Source: Coding guidelines

🤖 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 `@majit/majit-metainterp/src/blackhole.rs`:
- Around line 310-324: Replace the thread-local `GUARD_EXC_VALUE` storage with
the interpreter-owned root registry/stack or another established shared-root
mechanism used by the GC root walker. Update the associated `grab_exc_value`
handoff and root-walking access so the parked exception remains visible across
the blackhole transition without relying on TLS.
- Around line 326-347: Update GuardExcRoot::park and its Drop implementation to
use a GC-visible root stack or registry instead of storing the previous
exception as an unregistered i64. Ensure every active nested exception root is
walked and updated during collection, including nested park(0), and restore the
GC-updated prior value when each GuardExcRoot is dropped.

In `@majit/majit-metainterp/src/jitdriver.rs`:
- Around line 3480-3484: Update run_back_edge_generic to install a
GuardExcRoot::park for result_exc immediately after extracting it, and keep the
RAII guard alive through the allocation-capable bridge and blackhole resume
paths, including both prepare_resume_from_failure call sites. Preserve the
existing back_edge_internal protection and do not drop or move the guard before
handoff completes.

In `@pyre/bench/synth/getframe_while_captured_frame_outlives_call.py`:
- Around line 16-22: Replace the module-level scalar `kept` assignment in `leaf`
with a mutable holder such as `kept_box`, updating its element with `_gf()` and
removing the `global kept` declaration. Update the post-`main()` read to use the
holder so the captured frame remains alive without rebinding a module global.

In `@pyre/gate-triage.md`:
- Line 471: Update the PYRE_FBW_BLACKHOLE_RESUME retirement condition in
pyre/gate-triage.md to reflect that the multi-frame root-mismatch decline is
resolved. Keep the gate active until the remaining escaping sys._getframe
identity wrong-answer is fixed and its acceptance fixture passes.

---

Outside diff comments:
In `@pyre/pyre-jit/src/eval.rs`:
- Around line 3319-3357: Move GUARD_EXC_VALUE ownership out of TLS and into the
established interpreter/GC-root owner, preserving the guard exception across
blackhole resume without changing its identity semantics. In
pyre/pyre-jit/src/eval.rs lines 3319-3357, replace walk_guard_exc_value’s TLS
traversal with traversal of that durable owner; in pyre/pyre-jit/src/call_jit.rs
lines 1905-1909, pass or scope the owner through blackhole resume instead of
parking it in TLS; and in majit/majit-metainterp/src/trace_ctx.rs lines 541-544,
update the ownership contract to reflect the moved carrier.
🪄 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: d71da678-632e-4df5-9907-337975a32425

📥 Commits

Reviewing files that changed from the base of the PR and between 948340d and 08b29da.

📒 Files selected for processing (12)
  • majit/majit-metainterp/src/blackhole.rs
  • majit/majit-metainterp/src/jitdriver.rs
  • majit/majit-metainterp/src/trace_ctx.rs
  • pyre/bench/synth/getframe_while_caller_locals_across_subwalk.py
  • pyre/bench/synth/getframe_while_captured_frame_outlives_call.py
  • pyre/bench/synth/getframe_while_escaping_read_frame_identity.py
  • pyre/bench/synth/getframe_while_inlined_callee_subwalk.py
  • pyre/bench/synth/getframe_while_subwalk_decline_shapes.py
  • pyre/gate-triage.md
  • pyre/pyre-jit-trace/src/trace.rs
  • pyre/pyre-jit/src/call_jit.rs
  • pyre/pyre-jit/src/eval.rs

Comment on lines +310 to +324

/// llmodel.py:240 `grab_exc_value(deadframe)`: the exception a failing
/// guard carried, parked for the bridge / blackhole handoff.
///
/// Grabbing the value reads `jf_guard_exc` off the deadframe and drops the
/// jitframe, which was the collector's only handle on the exception
/// (`jitframe_trace`). The handoff then reconstructs the resume state
/// through the blackhole allocator before anything re-roots the value, so
/// in that window the exception — and the young `args` / `__traceback__`
/// reachable only through it — live behind a bare `i64`. RPython's
/// `grab_exc_value` result is a shadowstack-rooted local across the same
/// span; pyre has no GC transform, so the frontend registers a root walker
/// over this cell instead.
pub static GUARD_EXC_VALUE: std::cell::Cell<i64> = const { std::cell::Cell::new(0) };
}

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 | 🏗️ Heavy lift

Do not use TLS for GC-relevant exception state.

GUARD_EXC_VALUE is directly consumed by the GC root walker, so this is GC-relevant interpreter state. Move it to the interpreter-owned root registry/stack or another established shared-root mechanism; otherwise root visibility depends on thread-local storage and can miss the parked exception.

As per coding guidelines, TLS is prohibited for GC-relevant runtime state.

🤖 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/blackhole.rs` around lines 310 - 324, Replace the
thread-local `GUARD_EXC_VALUE` storage with the interpreter-owned root
registry/stack or another established shared-root mechanism used by the GC root
walker. Update the associated `grab_exc_value` handoff and root-walking access
so the parked exception remains visible across the blackhole transition without
relying on TLS.

Source: Coding guidelines

Comment on lines +326 to +347
/// Park a grabbed guard exception in [`GUARD_EXC_VALUE`] for the duration of
/// one handoff.
///
/// Restores the previous value on drop rather than clearing, so a nested
/// handoff (a bridge trace that itself deopts) unwinds to the exception its
/// caller is still carrying.
pub struct GuardExcRoot {
prev: i64,
}

impl GuardExcRoot {
pub fn park(exc: i64) -> Self {
Self {
prev: GUARD_EXC_VALUE.with(|cell| cell.replace(exc)),
}
}
}

impl Drop for GuardExcRoot {
fn drop(&mut self) {
GUARD_EXC_VALUE.with(|cell| cell.set(self.prev));
}

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 | 🏗️ Heavy lift

Nested GuardExcRoots can restore a collected pointer.

park replaces the only walked TLS slot and stores prev as an unregistered i64. During a nested handoff, the outer exception is therefore invisible to the GC; if allocation collects it, Drop restores a stale pointer. A nested park(0) has the same failure mode. Use a root stack/registry that walks and updates every active exception root.

🤖 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/blackhole.rs` around lines 326 - 347, Update
GuardExcRoot::park and its Drop implementation to use a GC-visible root stack or
registry instead of storing the previous exception as an unregistered i64.
Ensure every active nested exception root is walked and updated during
collection, including nested park(0), and restore the GC-updated prior value
when each GuardExcRoot is dropped.

Comment on lines +3480 to +3484
// The deadframe root died with the grab and the reconstruction
// below allocates through the blackhole allocator, so hold the
// exception where the frontend's root walker can reach it until
// `prepare_resume_from_failure` hands it to the blackhole.
let _guard_exc_root = crate::blackhole::GuardExcRoot::park(guard_exc);

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

Root the exception in every guard-failure handoff path.

This protects back_edge_internal, but run_back_edge_generic still drops its result after extracting result_exc and then performs allocation-capable resume work at Lines 5925 and 6013 without a GuardExcRoot. A GC during that work can reclaim or move the exception before it reaches prepare_resume_from_failure. Install the same RAII root immediately after extracting result_exc, keeping it alive through both bridge and blackhole paths.

🤖 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/jitdriver.rs` around lines 3480 - 3484, Update
run_back_edge_generic to install a GuardExcRoot::park for result_exc immediately
after extracting it, and keep the RAII guard alive through the
allocation-capable bridge and blackhole resume paths, including both
prepare_resume_from_failure call sites. Preserve the existing back_edge_internal
protection and do not drop or move the guard before handoff completes.

Comment on lines +16 to +22
kept = None


def leaf(x):
global kept
kept = _gf()
return x + 1

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

Avoid rebinding the module global from leaf.

global kept triggers Ruff PLW0603. Preserve module-lifetime storage with a mutable holder (for example, kept_box[0] = _gf()) and read that holder after main() so the captured frame remains alive without the warning.

Also applies to: 34-35

🧰 Tools
🪛 Ruff (0.15.21)

[warning] 20-20: Using the global statement to update kept is discouraged

(PLW0603)

🤖 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/bench/synth/getframe_while_captured_frame_outlives_call.py` around lines
16 - 22, Replace the module-level scalar `kept` assignment in `leaf` with a
mutable holder such as `kept_box`, updating its element with `_gf()` and
removing the `global kept` declaration. Update the post-`main()` read to use the
holder so the captured frame remains alive without rebinding a module global.

Source: Linters/SAST tools

Comment thread pyre/gate-triage.md
| var | subsystem | retire when |
|---|---|---|
| PYRE_FBW_BLACKHOLE_RESUME | single-frame resume-past-escape (#754) | flipped default-ON 2026-07-25; retirement was conditioned on the multi-frame twin (`_MULTIFRAME`) landing, but §1 now measures that twin as having zero corpus coverage, so the condition is unevaluable — keep the gate and re-open the question only once a benchmark reaches `inline_subwalk=true` at a vable escape |
| PYRE_FBW_BLACKHOLE_RESUME | single-frame resume-past-escape (#754) | flipped default-ON 2026-07-25; retirement was conditioned on the multi-frame twin (`_MULTIFRAME`) landing, but §1 now measures that twin as having zero corpus coverage, so the condition is unevaluable — keep the gate and re-open the question once the multi-frame adopt's root-mismatch decline (§1d) is resolved |

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 | 🟡 Minor | ⚡ Quick win

Update the stale retirement trigger.

The root-mismatch decline is documented as resolved on July 26, 2026. Keep this gate until the remaining escaping sys._getframe identity wrong-answer is fixed and its acceptance fixture passes.

🤖 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/gate-triage.md` at line 471, Update the PYRE_FBW_BLACKHOLE_RESUME
retirement condition in pyre/gate-triage.md to reflect that the multi-frame
root-mismatch decline is resolved. Keep the gate active until the remaining
escaping sys._getframe identity wrong-answer is fixed and its acceptance fixture
passes.

@github-actions

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 08b29da).
Updated: 2026-07-27T09:26:51.908Z

Files in the reviewed diff
majit/majit-metainterp/src/blackhole.rs
majit/majit-metainterp/src/jitdriver.rs
majit/majit-metainterp/src/trace_ctx.rs
pyre/bench/synth/getframe_while_caller_locals_across_subwalk.py
pyre/bench/synth/getframe_while_captured_frame_outlives_call.py
pyre/bench/synth/getframe_while_escaping_read_frame_identity.py
pyre/bench/synth/getframe_while_inlined_callee_subwalk.py
pyre/bench/synth/getframe_while_subwalk_decline_shapes.py
pyre/gate-triage.md
pyre/pyre-jit-trace/src/trace.rs
pyre/pyre-jit/src/call_jit.rs
pyre/pyre-jit/src/eval.rs

1. Regressions to PyPy parity introduced by this patch

  • pyre/pyre-jit-trace/src/trace.rs:1811 ↔ pypy/interpreter/executioncontext.py:85 — changing the root-identity gate from the tracing snapshot to live_root_addr now admits the multi-frame adopt, while its own comment records that an escaping sys._getframe() still observes the caller frame. PyPy enters each callee before executing it (enter() installs topframeref = virtual_ref(frame)); this path commits a one-level-shifted frame identity under PYRE_FBW_MULTIFRAME=1. Main declined this shape and replayed it correctly.

2. Other mismatches introduced by this patch

  • majit/majit-metainterp/src/blackhole.rs:339 ↔ rpython/jit/metainterp/pyjitpl.py:3125GuardExcRoot::park() replaces the sole TLS root with the nested handoff’s exception. Although Drop restores prev, the outer exception is unrooted during the nested handoff itself, so a collection there can reclaim/move it. PyPy’s translated exception locals remain independently GC-rooted on the call stack.

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

  • pyre/pyre-jit-trace/src/trace_opcode.rs:2573 ↔ pypy/interpreter/executioncontext.py:85 — inline tracing has no equivalent of ExecutionContext.enter()/jit.virtual_ref(frame) for an inlined callee. The Rust code instead expects virtualref_boxes to be empty; consequently an escaping residual executed during the inline subwalk sees the caller as topframeref. This is the pre-existing root cause that the changed multi-frame gate now exposes.

4. Structural adaptations

  • majit/majit-metainterp/src/blackhole.rs:323 ↔ rpython/jit/backend/llsupport/llmodel.py:240 — Pyre represents grab_exc_value() as a raw i64, requiring an explicit collector root walker; RPython keeps a typed GC reference. This is a Rust/collector adaptation, subject to the nested-root bug above.
  • pyre/pyre-jit-trace/src/trace.rs:1751 ↔ pypy/interpreter/executioncontext.py:85 — Pyre traces against a disposable snapshot_for_tracing while compiled execution owns a separate live frame; PyPy uses one virtual-ref-backed frame. The live/snapshot reconciliation is therefore structural, not a direct 1:1 port.

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