Skip to content

Two vable resume-frame defects: an unexecuted array read, and a promote stamped on the wrong op - #1140

Merged
youknowone merged 2 commits into
mainfrom
wasm-jit
Aug 10, 2026
Merged

Two vable resume-frame defects: an unexecuted array read, and a promote stamped on the wrong op#1140
youknowone merged 2 commits into
mainfrom
wasm-jit

Conversation

@youknowone

@youknowone youknowone commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Two stacked defects on the _nonstandard_virtualizable resume path, plus the hardening that keeps the second one from recurring silently.

1. The vable array read was recorded but never executed

_opimpl_getarrayitem_vable's port recorded the nonstandard fallback ops without performing the load, so the element box carried no concrete value. The trace then hit a ResidualCallArgUnbound abort with unjournaled effectsfbw_rolled_back_with_effects, i.e. the interpreter replayed work the trace had already committed.

Upstream (pyjitpl.py:1219) executes the load via opimpl_getarrayitem_gc_*. trace_ctx.rs now stamps the concrete at seven sites: the three vable_getarrayitem_{int,ref,float}_vable fallback legs, the GetfieldGcR array-base record in the three _indexed functions, and nonstandard_vable_array_base. A new live_gc_ptr helper rejects the unset marker, the all-ones tombstone, and NULL before any load.

2. The promote's resume position was stamped on the wrong op

Fixing (1) stopped the aborts, which let traces reach build_guard_metadata — where the _nonstandard_virtualizable promote GUARD_VALUE was still holding record_guard_with_snapshot's placeholder resume frame, and frame_value_count_at failed loud on it.

emit_force_virtualizable records GETFIELD_GC / PTR_NE / COND_CALL after the promote, so "last op" is not "last guard op". walker_capture_inline_nonstandard_vable_guard's inline branch already accounted for that; its root-frame branch did not, so the re-stamp landed on the COND_CALL and the promote kept the placeholder. Adds GuardCaptureScope::stamp_last_guard_op and routes both single-frame publishes through one publish_single_frame_snapshot.

3. The placeholder no longer aliases a live jitcode slot

The placeholder frame carried jitcode_index: 0, which is a real slot — so a missed re-stamp reads as a legitimate frame: the decoder sizes the frame from that entry's liveness and reads that many tagged words the placeholder never wrote. Measured on tests/abort_blackhole_virt_array.rs, the native frame_value_count answered 2 for a frame carrying 0 boxes.

recorder::UNSTAMPED_JITCODE_INDEX (-2) is reserved for it instead. The value is forced from both ends: rd_numb writes the field through append_int's i16 assert, so nothing above 32767 survives, and -1 is already create_empty_top_snapshot's index. recorder::tests::unstamped_jitcode_index_is_reserved_and_encodable pins all three constraints. Out of range, frame_value_count answers 0, and both pyre decoders now panic naming the missed re-stamp rather than reporting an opaque jitcode_index=0.

The doc paragraph arguing these guards are always removed before the backend is deleted — (2) was one that was not.

Verification

cargo test -p majit-metainterp green; dynasm 397/397, cranelift 397/397, wasm 395 passed. Four .jitstats baselines re-recorded where check.py itself reported the change as improved: raise_reg_unbound_jitstress (dynasm/cranelift/wasm), global_store_plain_dict_globals (wasm), pickle_terminal_raise_resume (wasm) — fbw_rolled_back_with_effects reaches 0 on all of them.

Those runs were taken at the pre-rebase base; this branch has since been rebased onto 0768a7cd41f with both commits carrying over byte-identical (git range-diff reports = for both).

Known follow-up

The native majit tier (#[majit_macros::jit_interp] example interpreters) has no re-stamp for the vable opcode arms at all, so its promotes are finalized with a coordinate-less frame. Not fixed here: the machinery to fix it is record_state_guard's snapshot half, which needs factoring out first. Filed separately.

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability when resuming optimized execution after virtualized frames and guard captures.
    • Added safeguards to prevent invalid or stale runtime references from being accessed.
    • Improved recovery of typed values from virtualized arrays, including integer, reference, and floating-point values.
    • Improved handling of resume positions so execution state is restored consistently.
  • Performance

    • JIT compilation now completes more loops with fewer rollbacks and aborted compilations across benchmark scenarios.

…ff the last guard op

`_opimpl_getarrayitem_vable`'s port recorded its nonstandard fallback ops
without executing them, so the element box carried no concrete and a
residual call taking it aborted the walk with unjournaled effects
(`fbw_rolled_back_with_effects`). `pyjitpl.py:1219` reaches the same
element through `opimpl_getfield_gc_r` + `opimpl_getarrayitem_gc_{i,r,f}`,
which execute the load and attach the value. Add `live_gc_ptr` plus
`stamp_vable_array_base` / `stamp_vable_array_item` and stamp the base and
the element at the seven recording sites.

Removing that abort let the affected traces reach `build_guard_metadata`,
which then panicked in `frame_value_count_at` on `jitcode_index=0 pc=0`.
`record_guard_with_snapshot` attaches a placeholder snapshot with
`jitcode_index: 0` to the `_nonstandard_virtualizable` promote, and
`walker_capture_inline_nonstandard_vable_guard` is what replaces it. Its
inline branch stamps the last *guard* op because `emit_force_virtualizable`
records GETFIELD_GC / PTR_NE / COND_CALL after the promote; its root-frame
branch stamped the last op, so the stamp landed on the COND_CALL and the
promote kept the placeholder. Add `GuardCaptureScope::stamp_last_guard_op`,
route both single-frame publishes through `publish_single_frame_snapshot`,
and set the flag in the root-frame branch.

Re-record the four improved jit-stats baselines:
  dynasm/cranelift raise_reg_unbound_jitstress  aborts 2->1, rolled_back 1->0, loops 8->9
  wasm raise_reg_unbound_jitstress              aborts 1->0, rolled_back 1->0, loops 6->7
  wasm global_store_plain_dict_globals          aborts 1->0, rolled_back 1->0, loops 5->6
  wasm pickle_terminal_raise_resume             aborts 14->9, rolled_back 5->0, loops 66->71

dynasm 397/397, cranelift 397/397, wasm 395 passed with the pre-existing
unary_negative jit-stats improvement still unrecorded (identical in the
clean-tree control).

Assisted-by: Claude
`record_guard_with_snapshot` mints a one-frame resume snapshot with no
coordinate for the interpreter-side vable promotes, and the walker
re-stamps it. The frame carried `jitcode_index: 0`, which is a live slot,
so a missed re-stamp read as a legitimate frame: the decoder sized the
frame from that entry's liveness and read that many tagged words the
placeholder never wrote. Measured on
`tests/abort_blackhole_virt_array.rs`, the native `frame_value_count`
answered 2 for a frame carrying 0 boxes.

Reserve `recorder::UNSTAMPED_JITCODE_INDEX` (`-2`) for it instead: `-1`
is `create_empty_top_snapshot`'s index and `rd_numb` writes the field
through `append_int`'s i16 check. Out of range, `frame_value_count`
answers 0, and both pyre decoders — `frame_value_count_at` and
`build_time_frame_value_count_at` — now panic naming the missed re-stamp
rather than returning 0 or reporting `jitcode_index=0`.

Also drops the doc paragraph arguing these guards are always removed
before the backend; 2c9f8bbb85b was one that was not.

Verified at base 2c9f8bbb85b: cargo test -p majit-metainterp green,
dynasm 397/397, cranelift 397/397, wasm 395 passed + the pre-existing
unrecorded `synth/unary_negative` jit-stats improvement.

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The change adds an explicit unstamped jitcode sentinel, recovers concrete virtualizable array values and pointers, targets snapshots at the correct guards, rejects unstamped resume frames, and updates benchmark statistics.

Changes

Virtualizable resume stamping

Layer / File(s) Summary
Unstamped resume coordinate contract
majit/majit-metainterp/src/recorder.rs, majit/majit-metainterp/src/history.rs
Snapshot frames use UNSTAMPED_JITCODE_INDEX for missing jitcode coordinates. Documentation and encoding tests define its constraints.
Concrete virtualizable load recovery
majit/majit-metainterp/src/trace_ctx.rs
Virtualizable array-base and array-item reads validate live pointers, recover concrete integer, reference, and float values, and stamp recorded operations when recovery succeeds.
Guard-targeted snapshot publication
pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs, pyre/pyre-jit-trace/src/jitcode_dispatch/resume_snapshot.rs
GuardCaptureScope can target the latest guard. Shared snapshot publication applies this behavior across full-body and per-opcode capture paths.
Resume validation and benchmark baselines
pyre/pyre-jit-trace/src/state.rs, pyre/bench/synth/*
Runtime and build-time liveness decoding reject unstamped coordinates. Synthetic JIT statistics reflect updated loop and rollback counts.

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

Sequence Diagram(s)

sequenceDiagram
  participant GuardCaptureScope
  participant publish_single_frame_snapshot
  participant SnapshotFrame
  participant state_liveness_decoder
  GuardCaptureScope->>publish_single_frame_snapshot: select the latest guard or recorded operation
  publish_single_frame_snapshot->>SnapshotFrame: attach the resume snapshot
  SnapshotFrame->>state_liveness_decoder: pass the jitcode coordinate
  state_liveness_decoder-->>SnapshotFrame: reject an unstamped coordinate
Loading

Possibly related PRs

Poem

A rabbit stamps each guard in line,
With concrete loads that now align.
Unstamped frames must halt and wait,
While typed array values navigate.
JIT loops leap through fields of green—
Thump thump! The snapshots stay clean.

🚥 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 names both main defects: the unexecuted vable array read and the promote stamped on the wrong operation.
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 wasm-jit

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@majit/majit-metainterp/src/recorder.rs`:
- Around line 679-696: Update the final assertion in
unstamped_jitcode_index_is_reserved_and_encodable to validate that converting
idx to usize fails, using a target-width-independent check such as
usize::try_from(idx).is_err(). Remove the comparison against u32::MAX while
preserving the existing assertions and message intent.
🪄 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: 1531cecb-cabe-44bc-9146-eb6169e9c383

📥 Commits

Reviewing files that changed from the base of the PR and between 0768a7c and daca5a1.

📒 Files selected for processing (11)
  • majit/majit-metainterp/src/history.rs
  • majit/majit-metainterp/src/recorder.rs
  • majit/majit-metainterp/src/trace_ctx.rs
  • pyre/bench/synth/global_store_plain_dict_globals.wasm.jitstats
  • pyre/bench/synth/pickle_terminal_raise_resume.wasm.jitstats
  • pyre/bench/synth/raise_reg_unbound_jitstress.cranelift.jitstats
  • pyre/bench/synth/raise_reg_unbound_jitstress.dynasm.jitstats
  • pyre/bench/synth/raise_reg_unbound_jitstress.wasm.jitstats
  • pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/resume_snapshot.rs
  • pyre/pyre-jit-trace/src/state.rs

Comment on lines +679 to +696
/// The three constraints that pick [`UNSTAMPED_JITCODE_INDEX`], asserted
/// together so a future edit to the value fails here instead of silently
/// re-aliasing a coordinate the decoder accepts.
#[test]
fn unstamped_jitcode_index_is_reserved_and_encodable() {
let idx = UNSTAMPED_JITCODE_INDEX as i32;
// Written to `rd_numb` through `resumecode::Writer::append_int`, which
// asserts the value round-trips through `i16`.
assert_eq!(idx as i16 as i32, idx, "must survive the rd_numb i16 write");
// `-1` is `create_empty_top_snapshot`'s own frame index.
assert_ne!(idx, -1, "collides with the empty-top-snapshot index");
// Every `frame_value_count` decoder resolves the frame with
// `jitcodes.get(jitcode_index as usize)`; the reserved value must miss.
assert!(
(idx as usize) > u32::MAX as usize,
"must be out of range for any jitcode table"
);
}

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

python - <<'PY'
for bits in (32, 64):
    idx = (-2) & ((1 << bits) - 1)
    max_u32_as_usize = ((1 << 32) - 1) & ((1 << bits) - 1)
    print(f"{bits}-bit: idx={idx}, u32::MAX as usize={max_u32_as_usize}, idx > max={idx > max_u32_as_usize}")
PY

rg -n -C 3 'unstamped_jitcode_index_is_reserved_and_encodable|u32::MAX as usize' majit/majit-metainterp/src/recorder.rs

Repository: youknowone/pyre

Length of output: 1041


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- const definition ---'
rg -n -C 4 'UNSTAMPED_JITCODE_INDEX' majit/majit-metainterp/src/recorder.rs

echo '--- relevant test context ---'
sed -n '660,705p' majit/majit-metainterp/src/recorder.rs

echo '--- Rust cross target semantics (rustc unavailable) ---'
python3 - <<'PY'
from pathlib import Path
p = Path('majit/majit-metainterp/Cargo.toml')
print('Cargo.toml exists:', p.exists())
if p.exists():
    print('\n'.join(f'{i}:{l}' for i,l in enumerate(p.read_text().splitlines(), 1)[:120]))

class R:
  pass
for name, bits in [('wasm32', 32), ('x86_64', 64)]:
    idx = (-2) & ((1 << 32) - 1)
    idx_usize = idx & ((1 << bits) - 1)
    print(f"{name}: idx as usize={idx_usize}, u32::MAX as usize={(1<<32)-1}, assertion_greater={idx_usize > ((1<<32)-1)}, usize_try_from={((-2) & ((1<bits)-1)) if idx_usize > (1<<bits)-1 else 'ok'}")
    print(f"{name}: usize::from_u32/try_from equivalent behavior: value outside usize range=", idx_usize > ((1<<bits)-1))
PY

Repository: youknowone/pyre

Length of output: 3512


Make the sentinel reservation test target-width independent.

On 32-bit targets, (-2i32 as u32) as usize is not greater than u32::MAX, so this test can fail on wasm32. Use a range check that does not depend on usize width, such as usize::try_from(idx).is_err().

🤖 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/recorder.rs` around lines 679 - 696, Update the
final assertion in unstamped_jitcode_index_is_reserved_and_encodable to validate
that converting idx to usize fails, using a target-width-independent check such
as usize::try_from(idx).is_err(). Remove the comparison against u32::MAX while
preserving the existing assertions and message intent.

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

ℹ️ About Codex in GitHub

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

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

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

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

Comment on lines +4912 to +4914
if let Some(Value::Int(item_index)) = self.concrete_of_opref(index) {
self.stamp_vable_array_item(op, array_opref, item_index, &descr, Type::Int);
}

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 Return the executed vable item value

When the native StandaloneFrameStack traces a nonstandard virtualizable array read, this newly executed load is discarded because the three vable_getarrayitem_*_descr helpers still return only an OpRef, while their _indexed callers return (op, None). Consequently, pyjitpl/dispatch.rs writes None into the destination concrete register, and the next read_*_reg panics with an uninitialized-concrete-register error despite the value now being present on the recorded op. Propagate the Option<Value> returned by stamp_vable_array_item through the int/ref/float nonstandard fallback paths, matching the standard fallback and upstream executed-Box behavior.

AGENTS.md reference: AGENTS.md:L231-L233

Useful? React with 👍 / 👎.

@github-actions

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit daca5a1).
Updated: 2026-08-10T06:05:04.764Z

Files in the reviewed diff
majit/majit-metainterp/src/history.rs
majit/majit-metainterp/src/recorder.rs
majit/majit-metainterp/src/trace_ctx.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/resume_snapshot.rs
pyre/pyre-jit-trace/src/state.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)

  • majit/majit-metainterp/src/trace_ctx.rs:4444 ↔ rpython/jit/metainterp/pyjitpl.py:1221: the nonstandard integer-array path returns (op, None) even though PyPy executes opimpl_getarrayitem_gc_i and returns an IntFrontendOp carrying its concrete runtime value (rpython/jit/metainterp/history.py:802). This predates the patch; the new OpRef stamping helps walker consumers, but this return channel remains absent.

  • majit/majit-metainterp/src/trace_ctx.rs:4639 ↔ rpython/jit/metainterp/pyjitpl.py:1221: the nonstandard float-array path likewise returns (op, None), whereas PyPy executes opimpl_getarrayitem_gc_f and returns a concrete-bearing FloatFrontendOp (rpython/jit/metainterp/history.py:804). This was already present before the patch.

  • majit/majit-metainterp/src/trace_ctx.rs:4860 ↔ rpython/jit/metainterp/pyjitpl.py:1253: the nonstandard arraylen_vable fallback calls opimpl_arraylen_gc(..., None), so the resulting box has no trace-time concrete length. PyPy executes ARRAYLEN_GC and returns its concrete result (rpython/jit/metainterp/pyjitpl.py:754-763). This was already present before the patch.

4. Structural adaptations

  • majit/majit-metainterp/src/recorder.rs:96 ↔ rpython/jit/metainterp/opencoder.py:567: UNSTAMPED_JITCODE_INDEX = -2 is a Rust-side sentinel for a side-table snapshot awaiting walker restamping. RPython encodes snapshots directly in its trace stream and has no analogous temporary jitcode-table coordinate.

  • pyre/pyre-jit-trace/src/state.rs:1267 ↔ rpython/jit/metainterp/resume.py:1049: Pyre explicitly panics if an unstamped frame reaches decoding; PyPy directly indexes the captured jitcode position. This is a fail-loud invariant around Pyre’s deferred snapshot publication, not a normal-path semantic divergence.

  • pyre/pyre-jit-trace/src/jitcode_dispatch/resume_snapshot.rs:1175 ↔ rpython/jit/metainterp/pyjitpl.py:2610: temporarily taking and restoring outer_active_boxes is a Rust borrow-management adaptation. It preserves PyPy’s capture order and captured frame contents.

@youknowone
youknowone merged commit 03bccc7 into main Aug 10, 2026
17 checks passed
@youknowone
youknowone deleted the wasm-jit branch August 10, 2026 08: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