Skip to content

exc: mark the traceback's frame escaped on app-level reads - #804

Merged
youknowone merged 1 commit into
mainfrom
perf-exc
Jul 26, 2026
Merged

exc: mark the traceback's frame escaped on app-level reads#804
youknowone merged 1 commit into
mainfrom
perf-exc

Conversation

@youknowone

Copy link
Copy Markdown
Owner

Upstream issues tb.frame.mark_as_escaped() from two getters — interp_exceptions.py descr_gettraceback and error.py OperationError.get_traceback — while direct _application_traceback reads (printing, chain trimming, got_any_traceback) do not. pyre had it from neither. This is what the Codex parity review on #768 flagged in both §2 and §3.

What this does

pytraceback::mark_traceback_escaped — the frame type is not visible from pyre-object, so it cannot sit beside the slot reader — called from the seven sites that mirror one of the two getters:

  • the __traceback__ getattr arm (descr_gettraceback)
  • the __traceback__ attribute fold
  • record_application_traceback (tb = operror.get_traceback())
  • eval.rs exception_trace (executioncontext.py tracer hand-off)
  • sys.exc_info (vm.py exc_info_with_tbget_w_traceback)
  • error.rs write_unraisable, twice (the value, and the exception the hook itself raised)

Left alone, because they mirror direct slot reads: write_traceback_chain_from_exc, the importlib bootstrap-frame trimming in importing.rs, and the exception-group metadata compare in builtins.rs.

The fold lowers the read to a raw GetfieldGc, so the specializer pairs it with a CallN to an extern "C" entry carrying cannot_raise_effect_info (the callee cannot raise, allocates nothing, and writes only the frame's status byte, which no field descriptor exposes to the trace), and applies the mark concretely on the walk.

Also corrects the PyTraceback.frame note that forbade dereferencing the pointer: pytraceback_object_custom_trace forwards that edge and frames are GC-owned since the FrameArena deletion, so tb_frame is live for the traceback's lifetime.

Scope — this restores a contract, it does not fix a bug

It changes no observed value, and measures free against a baseline binary (exception_metadata_hot 0.66s both, exception_traceback_loop_forms 0.62s both).

It was written for a real divergence — a same-frame raise+catch in a compiled while loop, traceback carried out, f.f_lineno reading the def line on pyre JIT against the return line on pypy3, CPython 3.14 and PYRE_NO_JIT=1 — but that divergence has a different root cause, and four hypotheses were built and refuted on the way:

  1. this missing mark. CONTROL: tb.tb_frame's getter has always marked the frame, and firing it from inside the handler is equally ineffective.
  2. force_frame(frame) in leave's escaped() || got_exception branch — no change. ExecutionContext::leave never runs for JIT-entered frames at all: the warm entry (try_function_entry_jit / handle_jitexception) sits outside execute_frame's enter/leave bracket, where upstream reaches the portal from dispatch, i.e. inside it. Instrumented: 0 leave events for the test function, 2 for <module>.
  3. force_frame at the traceback capture point in record_application_traceback — no change; that runs during unwinding.
  4. jit(fbw): emit ExecutionContext.enter/leave at the inlined-call push #796 (fbw, EC enter/leave at the inlined-call push) — tested against that branch's build, does not fix it either.

The boundary turns out to be the raise, not the handler:

sys._getframe() at function entry        -> correct
sys._getframe() inside try, BEFORE raise -> correct
sys._getframe() as handler's 1st stmt    -> SIGSEGV
no force                                 -> wrong (def line)

So last_instr is maintained correctly once the frame is forced before the raise; the compiled trace's exception path never writes the vable last_instr back, leaving the -1 that helpers.rs builds inline callee frames with (offset2lineno returns first_line_number for stopat < 0). That is a JIT exception-unwind defect, one family with two crashes found alongside it and both confirmed pre-existing by reproducing them on sibling worktree binaries built from unrelated HEADs:

  • traceback read plus repeated sys._getframe() exposure inside a compiled except handler SIGSEGVs (either read route — e.__traceback__ or sys.exc_info()[2]); one exposure is not enough, an exception is required
  • sys._getframe() placed after a traceback read in the same handler raises TypeError: call failed

That work belongs with the in-flight frame-lifecycle changes in #794 and #796 rather than against them, so it is not in this PR. The discriminator bench is deliberately not added to pyre/bench/synth/ — it would turn check.py red while the defect stands.

Verification

check.py dynasm 322/322 + cranelift 322/322 (sequential, on the rebased base); lib tests 400/326/294, 0 failed; cargo fmt --check clean; exception corpora diff clean against pypy3 on both backends.

Upstream issues `tb.frame.mark_as_escaped()` from two getters,
`descr_gettraceback` and `OperationError.get_traceback`, while direct
`_application_traceback` reads do not. pyre had it from neither.

Add `pytraceback::mark_traceback_escaped` -- the frame type is not
visible from pyre-object, so it cannot sit beside the slot reader -- and
call it from the seven sites that mirror one of the two getters: the
`__traceback__` getattr arm, the attribute fold,
`record_application_traceback`, the tracer hand-off in
`exception_trace`, `sys.exc_info`, and both `write_unraisable` reads.
The printing, chain-trimming and metadata-compare readers mirror direct
slot reads and are left alone.

The fold lowers the read to a raw slot load, so the specializer pairs it
with a `CallN` to an `extern "C"` entry carrying
`cannot_raise_effect_info`, and applies the mark concretely on the walk.

This restores the upstream contract; it does not change any observed
value. The `f_lineno` divergence it was written for survives, and a
control proves why: `tb.tb_frame`'s getter has always marked the frame,
and marking from inside the handler -- at either site -- has no effect,
while marking before the `try` does.

Also correct the `PyTraceback.frame` note that forbids dereferencing the
pointer: `pytraceback_object_custom_trace` forwards that edge and frames
are GC-owned, so `tb_frame` is live for the traceback's lifetime.

check.py dynasm 322/322 + cranelift 322/322; lib tests 400/326/294.

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in: 22 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: c5372c28-88e1-4a16-a0c8-be30537b12b9

📥 Commits

Reviewing files that changed from the base of the PR and between cb4f6bd and ecde196.

📒 Files selected for processing (7)
  • pyre/pyre-interpreter/src/baseobjspace.rs
  • pyre/pyre-interpreter/src/error.rs
  • pyre/pyre-interpreter/src/eval.rs
  • pyre/pyre-interpreter/src/module/sys/vm.rs
  • pyre/pyre-interpreter/src/pytraceback.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs
  • pyre/pyre-object/src/interp_exceptions.rs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf-exc

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

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

// `tb = operror.get_traceback()` — the read that grows the chain
// marks the previous head's frame, matching `get_traceback`.
let prev_tb = pyre_object::interp_exceptions::w_exception_get_traceback(w_exc_object);
mark_traceback_escaped(prev_tb);

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 Preserve the escape mark in the IR traceback prepend

When the full-body walker takes record_prepend_application_traceback for a nonconstant exception with a materialized frame, pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs:805-809 bypasses this function and lowers get_traceback() to only a GETFIELD_GC_R; it emits neither the concrete escape mark nor the residual marking call added for the attribute fold. Consequently, optimized JIT exception paths can still grow a traceback chain without marking the previous head's frame escaped, retaining the interpreter/JIT divergence this change is intended to remove. Pair that inline slot read with the same mark used by the opaque path.

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

Useful? React with 👍 / 👎.

@github-actions

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit ecde196).
Updated: 2026-07-25T23:52:18.306Z

Files in the reviewed diff
pyre/pyre-interpreter/src/baseobjspace.rs
pyre/pyre-interpreter/src/error.rs
pyre/pyre-interpreter/src/eval.rs
pyre/pyre-interpreter/src/module/sys/vm.rs
pyre/pyre-interpreter/src/pytraceback.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs
pyre/pyre-object/src/interp_exceptions.rs

1. Regressions to PyPy parity introduced by this patch

None.

2. Other mismatches introduced by this patch

None.

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

None.

4. Structural adaptations

  • pyre/pyre-object/src/interp_exceptions.rs:812 ↔ pypy/module/exceptions/interp_exceptions.py:195 — Rust keeps the raw traceback-slot read in the object crate and performs PyPy’s frame-escape side effect in an interpreter-crate helper; this is required because PyFrame is not visible to pyre-object.

  • pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs:1422 ↔ pypy/module/exceptions/interp_exceptions.py:195 — the JIT fold replaces the getter with a slot load, so it emits a separate non-raising residual call to preserve tb.frame.mark_as_escaped() during compiled execution.

  • pyre/pyre-interpreter/src/pytraceback.rs:282 ↔ pypy/interpreter/error.py:359 — PyPy represents “no traceback” as None; pyre represents the raw absent slot as a null PyObjectRef, with public callers converting it to None after applying the same escape transition.

  • pyre/pyre-interpreter/src/pytraceback.rs:42 ↔ pypy/interpreter/pytraceback.py:17 — PyPy’s traceback holds a normal managed frame reference; pyre stores a raw PyFrame pointer and relies on its GC custom trace to retain/forward GC-owned frames.

@youknowone
youknowone merged commit aaa3f22 into main Jul 26, 2026
17 of 19 checks passed
@youknowone
youknowone deleted the perf-exc branch July 26, 2026 05:40
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