Skip to content

jit: emit the lazy virtualizable store, and publish walk-time locals at a traceback escape - #1060

Merged
youknowone merged 5 commits into
mainfrom
fbw
Aug 6, 2026
Merged

jit: emit the lazy virtualizable store, and publish walk-time locals at a traceback escape#1060
youknowone merged 5 commits into
mainfrom
fbw

Conversation

@youknowone

@youknowone youknowone commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Three fbw fixes, plus the regression fixture and the jit-stats re-records they imply.

OptHeap::emit_lazy_setfield dropped a virtualizable store. It returned
without emitting whenever the stored value was still virtual and the target was
the standard virtualizable frame, so the per-slot SETARRAYITEM_GC that
gen_store_back_in_vable (rpython/jit/metainterp/pyjitpl.py:3489-3520) writes
at a trace exit was deleted for every slot holding a virtual box. Upstream
force_lazy_set (optimizeopt/heap.py:122-145) emits unconditionally — the
virtual-rhs skip belongs to force_lazy_sets_for_guard (heap.py:608-637), which
routes the op into rd_pendingfields instead of dropping it.

The walk-time traceback attach named a frame it had not published. The
recording walk steps a snapshot, so a STORE_FAST it performs does not reach
the live frame; storing that frame into a PyTraceback escapes the
virtualizable, so both attach sites now write the locals region first. The
inlined level needed a CalleeLocalsShadow-backed twin of
flush_locals_region_to_frame — the neighbouring
disarm_folded_inline_callee_after_escape covers only the strict fresh-frame
fold and is inert on the multiframe path.

Symptom for both: a callee that binds a local on one branch and raises answered
tb_frame.f_locals with its parameters alone — 859 of 4000 iterations before,
0 after, identical on dynasm and cranelift, 0 under PYRE_NO_JIT=1.
pyre/bench/synth/exception_vable_frame_virtual_local.py guards it.

The immortal-singleton enumeration test raced its siblings. It compared two
consecutive enumerations for equality over process-global OnceLock slots while
the test binary runs cases concurrently; it failed that way on the ubuntu
runner.

pyre/check.py: dynasm 387/387, cranelift 387/387, wasm 383/383.
cargo test --workspace --features dynasm: 101 suites, 0 failed.
Re-records closure_per_call.wasm (guard_failures 471 -> 468),
pickle_ctor_args.cranelift (436 -> 201), and adds the missing
exception_escape_hot_callee_tb_node_once.wasm baseline.

commented by Claude

Summary by CodeRabbit

  • Bug Fixes

    • Improved traceback accuracy by preserving virtualizable and inline-frame local variables during exception reporting.
    • Corrected garbage-collection handling for virtual references in frame value slots.
    • Unified lazy field and array store behavior for more reliable optimized execution.
  • Tests

    • Added regression coverage for exception traceback locals, frame-root scanning, and callee-local publication.
    • Updated benchmark statistics and expanded exception-handling performance coverage.
    • Improved singleton-enumeration tests for concurrent execution.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The PR unifies lazy field and array store emission, publishes virtualizable locals during traceback creation, adds explicit virtual-reference root scanning, adds exception traceback regression coverage, updates JIT statistics, and relaxes singleton enumeration test assumptions.

Changes

Lazy-store unification

Layer / File(s) Summary
Unified lazy-store emission
majit/majit-metainterp/src/optimizeopt/heap.rs
Field and array lazy stores now use one emitter signature. Obsolete RHS extractor helpers were removed.

Traceback frame state

Layer / File(s) Summary
Traceback local publication
pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs, pyre/pyre-jit-trace/src/jitcode_dispatch/tests.rs
Traceback paths flush virtualizable locals after validating frame ownership, concrete values, layout, and array capacity. Tests cover valid and rejected shadow states.
Virtual-reference root scanning
pyre/pyre-interpreter/src/eval.rs
Frame slot scanning now forwards virtual references and forced frames without raw PyObject-shaped walks.
Exception traceback regression coverage
pyre/bench/synth/exception_vable_frame_virtual_local.py, pyre/bench/synth/*.jitstats
The benchmark checks traceback locals across repeated exceptions. JIT statistics baselines were added or updated for the affected benchmarks.

Immortal singleton enumeration test

Layer / File(s) Summary
Concurrent singleton enumeration
pyre/pyre-object/src/interp_exceptions.rs
The test accepts process-global singleton additions between enumeration passes while retaining completeness and bound checks.

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

Possibly related PRs

Poem

A rabbit saw frames hop through traceback space,
With locals returned to their proper place.
Roots found their shadows, stores grew neat,
JIT counters marked the work complete.
“Hop onward!” cried the rabbit with glee.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two primary implementation changes: lazy virtualizable stores and traceback-time publication of locals.
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 fbw

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 9b9f539).
Updated: 2026-08-06T01:05:10.564Z

Files in the reviewed diff
majit/majit-metainterp/src/optimizeopt/heap.rs
pyre/bench/synth/exception_vable_frame_virtual_local.py
pyre/pyre-interpreter/src/eval.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/tests.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

  • pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs:759 ↔ rpython/jit/metainterp/virtualizable.py:101-113 — the new inline-frame “flush” mutates only the recording-time PyFrame; it emits no SETARRAYITEM_GC operations. The subsequently emitted runtime traceback hook at mod.rs:782 therefore runs without PyPy’s runtime write_boxes equivalent, so compiled executions can expose stale tb_frame.f_locals.

  • pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs:581 ↔ rpython/jit/metainterp/virtualizable.py:101-113flush_locals_region_to_frame is fallible but its result is discarded before attaching the traceback. PyPy’s write_boxes writes every virtualizable slot unconditionally; Pyre may attach a traceback to a frame whose locals were not publishable and remain stale.

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

  • pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs:782 ↔ pypy/interpreter/pytraceback.py:104-109 — the existing emitted inline traceback callback receives only (w_code, w_globals, jitcode, opcode), rather than the live frame. Its adapter creates a traceback-only frame, so compiled inline tracebacks do not preserve the executing callee’s frame identity or locals as PyPy’s PyTraceback(..., frame, ...) does. This predates the patch; the new recording-time flush does not alter that runtime call.

4. Structural adaptations

  • pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs:827-888 ↔ rpython/jit/metainterp/virtualizable.py:101-113CalleeLocalsShadow is a sparse Rust side representation, whereas PyPy uses the dense virtualizable_boxes sequence. The validation/decline logic is a Rust/JIT-walker adaptation required because the walker can lack concrete values; it is not a 1:1 upstream structure.

  • pyre/pyre-interpreter/src/eval.rs:801-821 ↔ rpython/jit/metainterp/virtualref.py:94-98 — explicit detection and root-walking of JitVirtualRef.forced substitutes for RPython’s typed GC traversal. It is a necessary representation/GC adaptation: Pyre’s virtual-ref header is not a normal Python object header.

  • pyre/pyre-object/src/interp_exceptions.rs:2219-2249 ↔ rpython/rtyper/exceptiondata.py:34-45 — weakening the singleton-enumeration test from set equality to subset containment accounts for concurrent Rust tests and process-global OnceLock-style singletons. RPython’s translated execution does not have this host-test concurrency model.

  • pyre/bench/synth/exception_vable_frame_virtual_local.py:1-49 ↔ rpython/jit/metainterp/pyjitpl.py:3489-3521 — this is a Pyre-specific Python 3.14 regression fixture exercising CPython-compatible bytecode/JIT plumbing; it has no direct PyPy fixture counterpart.

`walk_pyframe_roots_area` skips the PyObject-shaped raw walks for a
`locals_cells_stack_w` slot holding a `JitVirtualRef`, whose leading word is
the vtable magic rather than an `ob_type`. That guard sat inline in the loop
body with nothing in `cargo test` reaching it, and the bench fixture that
exercised it does not run in the suite.

Extract the slot body as `walk_frame_value_slot` — behaviour unchanged, both
existing comments carried over — and test it against a hand-built vref. The
test asserts the visitor is handed exactly the slot and then the vref's own
`forced` field. Removing the early return aborts the test with SIGABRT on the
type-pointer read, so the assertion is a gate rather than a restatement.

Assisted-by: Claude
…at a traceback escape

`OptHeap::emit_lazy_setfield` returned without emitting whenever the stored
value was still virtual and the target was the standard virtualizable frame,
so the per-slot `SETARRAYITEM_GC` that `gen_store_back_in_vable`
(`rpython/jit/metainterp/pyjitpl.py:3489-3520`) writes at a trace exit was
deleted for every slot holding a virtual box. `force_lazy_set`
(`rpython/jit/metainterp/optimizeopt/heap.py:122-145`) emits unconditionally;
the virtual-rhs skip belongs to `force_lazy_sets_for_guard` (heap.py:608-637),
which routes such an op into `rd_pendingfields` instead of dropping it.
Removing the carve-out leaves the `get_rhs` parameter, `field_get_rhs`,
`array_get_rhs` and `writes_into_virtualizable` unused; they are deleted.

The recording walk steps a snapshot of the frame, so a `STORE_FAST` it performs
does not reach the live frame. `record_top_level_application_traceback` and
`record_inline_application_traceback` store that frame into a `PyTraceback`,
which escapes the virtualizable, so both now write the locals region first —
the top level through `flush_locals_region_to_frame`, the inlined level
through a new `CalleeLocalsShadow`-backed twin. The neighbouring
`disarm_folded_inline_callee_after_escape` covers only the strict fresh-frame
fold and is inert on the multiframe path.

A callee that binds a local on one branch and raises answered
`tb_frame.f_locals` with its parameters alone: 859 of 4000 iterations before,
0 after, identical on dynasm and cranelift. Adds
`pyre/bench/synth/exception_vable_frame_virtual_local.py`.

Re-records `closure_per_call.wasm` (guard_failures 471 -> 468),
`pickle_ctor_args.cranelift` (436 -> 201), and the missing
`exception_escape_hot_callee_tb_node_once.wasm` baseline.

Assisted-by: Claude
…lity

`immortal_singleton_enumeration_reports_created_and_forces_none` asserted that
two consecutive `for_each_immortal_exception_singleton` passes report the same
set. The slots are process-global `OnceLock`s and the test binary runs its
cases concurrently, so a sibling case creating another `ExcKind` between the
two passes fails the equality on its own; it did on the ubuntu runner.

Assert instead that the second pass still reports every singleton the first one
did and stays within `EXC_KIND_COUNT + 1`. The reads go through `OnceLock::get`,
which is what makes the enumeration allocation-free.

Assisted-by: Claude
@youknowone youknowone changed the title gc: cover the frame-slot virtual-ref skip with a test jit: emit the lazy virtualizable store, and publish walk-time locals at a traceback escape Aug 5, 2026

@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: 1dca827562

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

// publish the walk's locals first. `virtualizable.py:101-138
// write_boxes` makes that write unconditional, and `pyopcode.py:148`
// performs it before attaching the application traceback.
crate::state::flush_locals_region_to_frame(ctx.trace_ctx, frame_ptr);

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 Add rollback around traceback locals flush

When a full-body walk records a traceback and is later discarded, this new concrete flush writes the walk snapshot's locals into the live frame without the escape-flush undo that state::flush_locals_region_to_frame expects its callers to have armed. The traceback head itself is journaled and rolled back for non-committing walks, but these frame-slot writes are not, so a declined/rolled-back trace can leave the interpreter replaying with speculative locals already published. Please snapshot/restore the frame state around this traceback-escape flush, as the residual escape path does before calling the same helper.

Useful? React with 👍 / 👎.

…ete half

`flush_callee_locals_region_to_frame` skipped every slot absent from
`CalleeLocalsShadow::concrete`. That map is cleared by `set_concrete` when the
stored value carries no concrete half, so "absent" covered both a slot the walk
never wrote and a slot it wrote with an unknown value; the second left the
frame's stale value visible in the traceback.

`opref` witnesses the write in both cases, so the validation pass now declines
when a locals slot has an `opref` entry without a `concrete` one, and also on
`Value::Ref(GcRef::NO_CONCRETE)`. Extracted as
`callee_locals_region_is_publishable` with a unit test.

Assisted-by: Claude
@youknowone

Copy link
Copy Markdown
Owner Author

Codex parity review — disposition

§1 Regressions: none reported.

§2 Other mismatches (1) — fixed in-session.

mod.rs ↔ virtualizable.py:101-138 write_boxes — the callee flush skipped absent shadow entries.

The finding is real. Upstream's virtualizable_boxes is total, so write_boxes can write every locals-array element unconditionally; CalleeLocalsShadow is sparse, so an absent slot was ambiguous:

  • absent from both opref and concrete — the walk never wrote the slot, so the frame already holds the value write_boxes would have written. Skipping is correct.
  • present in opref but not in concrete — the walk did write it, with no concrete half (set_concrete drops a Value::Void). The frame's value is stale and unreconstructable.

Only the second case is a defect, and opref is the write-witness that separates them (every set_concrete call site is paired with a set_opref at the same slot). The validation pass now declines the whole flush on that case, restoring the all-or-nothing discipline of the top-level flush_locals_region_to_frame. It also declines on Value::Ref(GcRef::NO_CONCRETE), which would otherwise store usize::MAX - 1 into a GC-traced slot. Extracted as callee_locals_region_is_publishable with a unit test covering all four cases.

§3 Pre-existing (2) — both deferred, blocker named.

  1. CalleeLocalsShadow HashMap side tables vs MIFrame register arrays — blocked: pyre lowers callee LOAD_FAST/STORE_FAST to getarrayitem_vable/setarrayitem_vable(frame, slot) while the WalkContext register banks are post-regalloc colour-indexed. Making frame-owned register state authoritative means allocating MIFrame-style per-slot registers for inlined callees in the codewriter, which cascades through the jitcode lowering, regalloc and every vable op. The §2 fix narrows the harm meanwhile: a sparse entry now declines instead of publishing a stale value.
  2. Unseeded inline calls fabricate a traceback-only frame from code/globals — blocked: a branchless-leaf inline level never materialises a PyFrame at all (that is what the inline fold buys), so its shadow has concrete_frame == 0 and there is nothing to flush into. Filling those locals requires porting virtualizable.py force_now for fresh inline frames.

§4 Structural adaptations (2) — both won't-fix, documentation confirmed present.

  1. eval.rs explicit GC root walk + JitVirtualRef tag check — required by pyre's movable GC / raw-pointer representation; the in-code comment cites virtualref.py:94-98 is_virtual_ref(gcref).
  2. Boxing Int/Float and the explicit remembered-set barrier — the Rust-runtime form of RPython's typed unwrap plus GC write barrier; cited at the store.

commented by Claude

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

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

// first. `virtualizable.py:101-138 write_boxes` makes that
// write unconditional before `pyopcode.py:148` attaches the
// application traceback.
flush_callee_locals_region_to_frame(ctx, frame_ptr, frame_reg);

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 Gate inline traceback attachment on locals flush

When an inlined callee has a materialized frame but flush_callee_locals_region_to_frame declines (for example a locals slot was written with no concrete half, a foreign frame register, or an unavailable shadow), this branch still records that live frame into the traceback. Because the decline leaves the frame array untouched, tb_frame.f_locals can expose stale locals from before the walk; the branch should fall back/decline instead of attaching the unpublishable frame.

AGENTS.md reference: AGENTS.md:L24-L26

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

🤖 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/mod.rs`:
- Around line 880-886: In the frame-array update path, replace the direct
`as_mut_slice()[abs] = boxed` assignment with `FixedObjectArray::set_ref`,
ensuring the value is rooted and the array barrier runs before the store.
Preserve the existing `frame_array_write_barrier` call afterward so the
enclosing frame remains marked.

In `@pyre/pyre-object/src/interp_exceptions.rs`:
- Around line 2227-2241: Strengthen the test around
for_each_immortal_exception_singleton by preventing concurrent singleton
creation: pre-initialize every reportable exception slot or acquire the shared
test lock used for singleton creation. Then require the second enumeration to
contain exactly the previously observed pointers, while retaining the existing
boundedness and real-object checks.
🪄 Autofix

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: 4077a0a4-8287-41d6-9738-ba2ec91a6737

📥 Commits

Reviewing files that changed from the base of the PR and between da5e6fb and b5becf1.

📒 Files selected for processing (12)
  • majit/majit-metainterp/src/optimizeopt/heap.rs
  • pyre/bench/synth/closure_per_call.wasm.jitstats
  • pyre/bench/synth/exception_escape_hot_callee_tb_node_once.wasm.jitstats
  • pyre/bench/synth/exception_vable_frame_virtual_local.cranelift.jitstats
  • pyre/bench/synth/exception_vable_frame_virtual_local.dynasm.jitstats
  • pyre/bench/synth/exception_vable_frame_virtual_local.py
  • pyre/bench/synth/exception_vable_frame_virtual_local.wasm.jitstats
  • pyre/bench/synth/pickle_ctor_args.cranelift.jitstats
  • pyre/pyre-interpreter/src/eval.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/tests.rs
  • pyre/pyre-object/src/interp_exceptions.rs

Comment on lines +880 to +886
let boxed = crate::state::boxed_slot_value_for_type(Type::Ref, &concrete.value);
unsafe {
(*arr_ptr).as_mut_slice()[abs] = boxed;
}
// Boxing an Int/Float slot allocates, and each minor collection
// consumes the array's remembered-set entry, so re-arm per store.
crate::state::frame_array_write_barrier(frame as *mut u8, arr_ptr);

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

Use the GC-aware array store before the frame barrier.

Line 882 stores boxed into an old frame array before Line 886 marks the array. A concurrent minor collection can run between these operations and miss the new young reference. This can leave a dangling frame-local pointer.

Use FixedObjectArray::set_ref for the slot store. It roots the value and performs the array barrier before the store. Keep frame_array_write_barrier afterward to mark the enclosing frame.

Proposed fix
-        unsafe {
-            (*arr_ptr).as_mut_slice()[abs] = boxed;
-        }
+        unsafe {
+            (&mut *arr_ptr).set_ref(abs, boxed);
+        }
         // Boxing an Int/Float slot allocates, and each minor collection
         // consumes the array's remembered-set entry, so re-arm per store.
         crate::state::frame_array_write_barrier(frame as *mut u8, arr_ptr);
📝 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
let boxed = crate::state::boxed_slot_value_for_type(Type::Ref, &concrete.value);
unsafe {
(*arr_ptr).as_mut_slice()[abs] = boxed;
}
// Boxing an Int/Float slot allocates, and each minor collection
// consumes the array's remembered-set entry, so re-arm per store.
crate::state::frame_array_write_barrier(frame as *mut u8, arr_ptr);
let boxed = crate::state::boxed_slot_value_for_type(Type::Ref, &concrete.value);
unsafe {
(&mut *arr_ptr).set_ref(abs, boxed);
}
// Boxing an Int/Float slot allocates, and each minor collection
// consumes the array's remembered-set entry, so re-arm per store.
crate::state::frame_array_write_barrier(frame as *mut u8, arr_ptr);
🤖 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/mod.rs` around lines 880 - 886, In
the frame-array update path, replace the direct `as_mut_slice()[abs] = boxed`
assignment with `FixedObjectArray::set_ref`, ensuring the value is rooted and
the array barrier runs before the store. Preserve the existing
`frame_array_write_barrier` call afterward so the enclosing frame remains
marked.

Comment on lines +2227 to +2241
// Enumerating must not initialize a slot: a second pass still reports
// every singleton the first one did, stays within the bound, and hands
// back real exception objects. The slots are process-global and the
// test binary runs its cases concurrently, so a sibling case creating
// another kind in between makes the second set a superset — comparing
// the two for equality would fail on that alone.
let mut again = Vec::new();
for_each_immortal_exception_singleton(|exc| again.push(exc as usize));
assert_eq!(seen, again, "enumeration must not create singletons");
for raw in &seen {
assert!(again.contains(raw), "enumeration must not drop a singleton");
}
assert!(
again.len() <= EXC_KIND_COUNT + 1,
"enumeration is bounded by the per-kind slots plus MemoryError"
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve the no-initialization assertion.

The superset check is necessary for concurrent sibling tests, but it also allows for_each_immortal_exception_singleton to create a new singleton during the second pass and still pass. Lines [2235-2237] prove only that previously observed pointers remain. The bound does not distinguish a concurrent addition from an enumeration-created entry. Pre-initialize every reportable slot or use a shared test lock for singleton creation, then assert that the second pass adds no pointer.

🤖 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-object/src/interp_exceptions.rs` around lines 2227 - 2241,
Strengthen the test around for_each_immortal_exception_singleton by preventing
concurrent singleton creation: pre-initialize every reportable exception slot or
acquire the shared test lock used for singleton creation. Then require the
second enumeration to contain exactly the previously observed pointers, while
retaining the existing boundedness and real-object checks.

@youknowone

Copy link
Copy Markdown
Owner Author

Codex parity review, round 2 (b5becf1) — disposition

§1 and §2 are now both None; the round-1 §2 finding is closed.

§3 (1) — deferred, blocker named, reachability measured.

mod.rs ↔ virtualizable.py:101 — when flush_callee_locals_region_to_frame declines, the traceback is still attached to a frame whose locals were not published. The classification is right: this is the residual of the pre-existing sparse-shadow adaptation, and the patch narrows it rather than removing it.

Measured before deciding. A temporary decline counter built into pyre-dynasm and run over the whole synthetic corpus:

scanned=370 benches_with_declines=0 total_declines=0

plus 0 declines on exception_vable_frame_virtual_local itself (2000 / 2000 both buckets correct). The decline path is currently unreached; the instrumentation was reverted after the measurement and is not in the diff.

Eliminating it — rather than narrowing it — means making the shadow total, which is the same blocker as round 1's §3 item 1: callee LOAD_FAST/STORE_FAST lower to getarrayitem_vable(frame, slot) while the WalkContext register banks are post-regalloc colour-indexed, so a total virtualizable_boxes equivalent requires MIFrame-style per-slot registers for inlined callees in the codewriter, cascading through the jitcode lowering, regalloc and every vable op. Out of scope for this diff; deferred with that dependency named.

The two alternatives available without that port were both rejected as inventions rather than ports: falling back to the fabricated code/globals frame loses frame identity as well as the locals, and NULL-ing the unreproducible slots substitutes a different wrong answer for the stale one. The all-or-nothing decline at least matches the discipline the top-level twin flush_locals_region_to_frame already follows.

§4 (3) — all won't-fix, documentation confirmed present (sparse-shadow validation, JitVirtualRef.forced GC edge, and the relaxed singleton-enumeration test for concurrent access to process-global slots).

commented by Claude

@youknowone
youknowone merged commit fa2eda0 into main Aug 6, 2026
@youknowone
youknowone deleted the fbw branch August 6, 2026 01:00
youknowone added a commit that referenced this pull request Aug 6, 2026
…tribute__ deviations (#1079)

* _io: port BytesIO and StringIO from app level to interp level

`class BytesIO` and `class StringIO` in `_io_app.py` are replaced by
`W_BytesIO` (bytesio.rs) and `W_StringIO` (stringio.rs), following
`pypy/module/_io/interp_bytesio.py` and `interp_stringio.py`.
`_io_app.py` keeps only `IncrementalNewlineDecoder`.

Both types hold their storage in a GC object field: `W_BytesIO` a
`bytearray`, `W_StringIO` an `array('w')` of code points, standing in
for the `RStringIO`/`UnicodeIO` split that exists because RPython
strings are immutable.

The two classes are registered at the tail of the three GC censuses
(`build_gc`, `all_subclass_range_aliases`, `SUBCLASS_RANGE_HIERARCHY`)
as ids 160 and 161. `tag_io_instance_with_finalizer` is split so
`W_BytesIO` can pass `add_to_autoflusher=False` (interp_bytesio.py:70).

Methods that can run Python (`buffer_w`, `__index__`, `dict.update`)
re-derive the receiver from a pinned root afterwards, because a
collection inside such a callback moves the stream and leaves the
entered `&mut self` behind the forwarding pointer.

lib-python `test_memoryio` goes from IMPORTERROR to 183 tests, 0
errors, 0 failures. `synth/pickle_ctor_args` runs 0.80s -> 0.28s
(dynasm) and 0.84s -> 0.28s (cranelift); its jitstats and those of
`synth/pickle_terminal_raise_resume` are re-recorded, both losing the
function-entry loops that traced the removed app-level methods.

Assisted-by: Claude

* objspace: run the canonical type.__getattribute__ body directly

`getattr_str_impl` reaches the metatype `__getattribute__` slot for every
type receiver. `type` defines `__getattribute__`, so
`getattribute_if_not_from_object` returns it and the slot was invoked
through `get_and_call_function` — wrapping the name into a `w_str`,
entering callable dispatch, and re-validating the name through
`core::str::from_utf8` — only to reach `typeobject.py:811-828`
`W_TypeObject.descr_getattribute`, whose body `object_getattr_miss`
already inlines below.

`is_type_getattribute_descr` recognises that descriptor by identity
against `type`'s own slot (typeobject.py:1322), the same shape
`is_object_getattribute_descr` uses for `object`. A metaclass that
overrides `__getattribute__` keeps the descriptor-call path.

800k `getattr(SubClass, name)`, medians of 7 interleaved runs:
ascii names 0.344s -> 0.238s (-31%), lone-surrogate names 0.451s ->
0.443s (the surrogate path never entered this dispatch).

A 54-case type-attribute conformance probe — metaclass `__getattr__`
hooks, `__getattribute__` overrides, metatype data descriptors,
descriptor `__get__` raising AttributeError, abc/enum, attribute
mutation, and installing `__getattribute__` on the metaclass after the
fact — produces byte-identical output before and after, and matches
cpython3.14 on 52 of those 54 lines.

`synth/type_metatype_method_call` loses one wasm guard failure with the
residual call.

Assisted-by: Claude

* objspace: object.__getattribute__ reads the receiver namespace, not a type's MRO

`object_getattribute`'s non-instance tail delegated to `getattr_str_impl`,
so a type receiver ran `typeobject.py:811-828`
`W_TypeObject.descr_getattribute` — the class-MRO walk.
`object.__getattribute__(Sub, "b")` therefore returned the value
inherited from `Base`; cpython3.14 and pypy3 both raise AttributeError.

descroperation.py:88-112 `Object.descr__getattribute__` looks the name up
with `space.lookup(w_obj, name)` — the metatype for a type object — and
reads only `w_obj.getdictvalue`, never the receiver type's own MRO. The
type receiver now shares the instance arm with the metatype as lookup
type and the type's own namespace as the receiver dict.

`type.__getattribute__` keeps the MRO walk: typedef.rs routes its slot to
a named `type_getattribute` instead of the object default.

`attr_error_wtf8` reported `'type' object has no attribute` for a type
receiver where the `&str` path already reported
`type object 'Sub' has no attribute`. Both now share
`missing_attribute_subject`, and the message is built as WTF-8 so a lone
surrogate survives into `AttributeError.name` and `.obj`.

The 54-case type-attribute conformance probe now matches cpython3.14 on
every line, on dynasm and cranelift alike; it matched on 52 before.
Vendored test_descr (162), test_funcattrs (35), test_descrtut, test_super
(40), test_enum (1081), test_abc (72) and test_property (31) report
identical counts to a build without this change.

Assisted-by: Claude

* jitstats: re-record the pickle fixtures the _io port moves

`pickle_ctor_args` and `pickle_terminal_raise_resume` lose the
function-entry loops that traced the app-level `_io.BytesIO` methods:
loops_compiled 4 -> 2 and 36 -> 31 (wasm 73 -> 68), with
`pickle_ctor_args` cranelift also dropping its one bridge and its
guard failures 201 -> 1. `loops_aborted` is unchanged on every backend.

Assisted-by: Claude

* jitstats: record the four wasm guard-failure counts the rebase base moves

`closure_per_call` 470 -> 468, `exception_traceback_frame_lineno`
820 -> 819, `recursive_call_frame_relocation` 649 -> 648 and
`gc_iterator_source_drop` 613 -> 614 on wasm.

These are not this branch's: check.py ran wasm 383/383 on the previous
base with both objspace commits already applied, and the four moved only
after rebasing onto 1de95e0, which carries #1060, #1072 and #1047 —
all three change guard emission. Each count reproduces exactly across
repeated runs, so it is a transition and not the back-edge poll
oscillation. dynasm and cranelift are 388/388 either way.

Assisted-by: Claude

* _io: cite the close-while-exported divergence in W_BytesIO::close

`interp_bytesio.py:194` `close_w` delegates straight to `RStringIO.close`
with no export check, so it releases the storage under a live
`getbuffer()` result. `_io.BytesIO.close` raises `BufferError: Existing
exports of data: object cannot be re-sized` in that state, which the
`check_exports()` call here already reproduced; only the comment naming
the upstream line was missing.

Comment-only change.

Assisted-by: Claude
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