Skip to content

jit: three walk-image defects — locals-typed vable overlay, unpublished escape root stack, unmodeled LOAD_SPECIAL - #1051

Merged
youknowone merged 3 commits into
mainfrom
nbody
Aug 5, 2026
Merged

jit: three walk-image defects — locals-typed vable overlay, unpublished escape root stack, unmodeled LOAD_SPECIAL#1051
youknowone merged 3 commits into
mainfrom
nbody

Conversation

@youknowone

@youknowone youknowone commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Three independent defects on the walk / blackhole-adopt path, each of which lets a
frame resume from an image that does not describe the state the residual left behind.
All three were found with the same probe method — run the asyncio suite under
PYRE_FBW_CENSUS=1 and treat committed=false AND effects>0 as the hazard predicate —
and each is verified by a deterministic counter, not by the failure rate.

1. resume_snapshot.rs — the after-residual vable overlay wrote LOCAL slots

The overlay that re-projects live Ref registers after a residual call used the
per-PC pcdep map to decide which slot a register belongs to. pcdep is keyed by
program point, not by binding: two registers live at the same PC are indistinguishable
to it. When a local slot and an operand-stack slot were both live across the residual,
the overlay could publish the wrong register into the local slot — a self slot holding
a code object, among other faces.

Restrict the overlay to operand-stack slots, where the pcdep label is the identity.
Locals keep the ordinary snapshot value.

2. residual_call.rs / trace.rs — the vable-escape single-frame adopt published no root operand stack

try_adopt_single_frame_blackhole latched a MIFrame without publishing the root
operand stack, so a resumed getarrayitem_vable_r against that frame read NULL.

The tell is that the [fbw-escape] diagnostic line is not the image — there are two
latch producers and only one of them published the stack. The VableEscape leg now
publishes the root stack like the other producer. 6/6 → 0/6 on the repro.

3. vstack_mirror.rsLOAD_SPECIAL was unmodeled, so every with body killed the walk mirror

The root cause behind the residual asyncio instability. Every link verified in source
and measured:

with / async with            ->  LOAD_SPECIAL
classify_vstack_opcode       ->  falls through `_ => VstackOpClass::Unmodeled`
apply arm                    ->  ctx.vstack_valid = false   (STICKY for the walk)
mod.rs:8912                  ->  depth_gt_1 && !vstack_valid
                                 => DispatchError::BranchGuardKeptStackUnsupported
leaves_complete_image()      ->  does NOT list it  => no adopt leg covers it
run_perfn_walk epilogue      ->  committed = false
store journal                ->  cannot undo a residual that entered a Python frame
interpreter                  ->  REPLAYS the region
asyncio                      ->  _ready.append / Handle._run / Context.run applied twice

That double application accounts for the whole observed symptom set:
InvalidStateError("FINISHED: …"), RuntimeError: cannot enter context … already entered,
AttributeError: 'NoneType' object has no attribute '_source_traceback', and
TypeError: 'builtin_function_or_method' object is not an iterator.

Instruction::LoadSpecial { method } restricted to SpecialMethod::Enter | Exit now
classifies as VstackOpClass::MultiResultFromShadow.

Why that class and not ResultToTos. LOAD_SPECIAL pops the manager at
prev_depth - 1 and pushes two values. ResultToTos only writes [new_depth-1],
leaving the popped object as a stale non-NONE box where the bound method belongs — and
both hole-fill helpers skip non-NONE slots (if *slot != OpRef::NONE { continue; } in
reseed_vstack_from_shadow and reseed_vstack_from_callee_shadow). A stale box is
strictly worse than an invalid mirror. MultiResultFromShadow NONEs
[pop_point .. new_depth) and lets the shadow source each pushed slot; unsourceable slots
stay NONE and the kept-stack check declines — never a corrupt box. Premise verified: both
pushed values go through emit_pushvalue_ref!setarrayitem_vable_r
(codewriter.rs 12068 / 12083 / 7716).

AEnter / AExit are deliberately left Unmodeled (permanent-abort lowering).

Measurement

400-round asyncio harness, one-binary A/B behind a temporary env gate:

counter before after
mirror invalidations (op=LoadSpecial arg=OpArg(1)) 5 0
BranchGuardKeptStackUnsupported declines 5 0
walks aborting on that branch guard 5 0
all committed=false AND effects>0 walks 11 6

Before the fix the three counters are one-to-one — every mirror kill produced exactly
one decline and exactly one uncommitted-with-effects walk, and all five kills were the
identical __exit__ site.

End-to-end, as a supporting (two-binary, underpowered) number: the same harness at
600 rounds × 10 tries went from 9/10 tries completing — 1 class-B failure, 2 other
failures, 1 hard non-zero exit — to 10/10 clean, zero failures of any class.

Refuted along the way (recorded so they are not re-derived)

  • Bridge double-execution rewind at call_jit.rs:3709 — the comment there describes a
    real but unfiring path.
  • PYRE_WALKABORT_OFF=1 — no change; that leg only covers leaves_complete_image()
    errors, and this abort is not one.
  • Two synthetic minimal repros of the _run_once drain shape — 0 hits. The abort needs
    the real warmed workload.
  • Modeling the await opcodes (SEND / GET_AWAITABLE / GET_AITER / GET_ANEXT) —
    A/B'd, decline_why 5 in both arms. Reverted rather than landed unverified.

Known follow-ups (not in this PR)

  1. The remaining 6 hazards are VableEscapedDuringResidualCall committed=false. Per-walk
    pairing shows two causes: the latch is skipped entirely for a bridge trace
    (!ctx.trace_ctx.is_bridge_trace, residual_call.rs:3081 — listed as a precondition in a
    comment that explicitly refutes the other gates), and the inline-sub-walk arm declines
    on odo_unchanged=false (residual_call.rs:3142-3143), which is exactly the case where
    replay is unsound.
  2. END_SEND, YIELD_VALUE, AEnter, AExit remain Unmodeled in the mirror.
  3. The structural gap: a pyre walk can execute an irreversible residual and then reach a
    state from which no image can be built. Upstream cannot — _copy_data_from_miframe
    copies the banks unconditionally and has no failing path (blackhole.py:1711-1730,
    cited at mod.rs:2338-2341).
  4. cpython_tests: record test.test_asyncio as TIMEOUT #1017 recorded test.test_asyncio as TIMEOUT; worth revisiting now.

Summary by CodeRabbit

  • Bug Fixes
    • Improved stack-state handling when tracing stops inside an operation or exceeds trace limits.
    • Fixed recovery of operand-stack values during virtualizable escapes and aborted walks.
    • Improved context-manager stack reconciliation for enter and exit operations.
    • Prevented local variables from being incorrectly treated as operand-stack values during snapshot restoration.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2fbf5a93-ddfb-46b8-9d33-8e155b2f56c6

📥 Commits

Reviewing files that changed from the base of the PR and between ff39470 and af8f4d8.

📒 Files selected for processing (4)
  • pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/resume_snapshot.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/vstack_mirror.rs
  • pyre/pyre-jit-trace/src/trace.rs

Walkthrough

The changes update operand-stack mirror handling for residual-call aborts and escapes. Snapshot reconciliation now excludes local slots and handles Enter and Exit as shadow-backed multi-result operations. Single-frame blackhole paths publish the appropriate root operand stack.

Changes

Blackhole stack reconciliation

Layer / File(s) Summary
Virtual stack snapshot reconciliation
pyre/pyre-jit-trace/src/jitcode_dispatch/vstack_mirror.rs, pyre/pyre-jit-trace/src/jitcode_dispatch/resume_snapshot.rs
Enter and Exit use multi-result shadow-backed handling. Residual-call reconciliation skips local-variable slots.
Blackhole operand-stack publication
pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs, pyre/pyre-jit-trace/src/trace.rs
Walk-abort and vable-escape paths publish resolved walker mirrors. Boundary ABORT_TOO_LONG paths retain frame snapshot stacks.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Trace
  participant RootWalk
  participant ResidualCall
  participant SingleFrameBlackhole
  Trace->>RootWalk: stop on WalkAbort or VableEscape
  RootWalk->>ResidualCall: provide resolved operand-stack mirror
  ResidualCall->>SingleFrameBlackhole: store mirror_stack
  Trace->>SingleFrameBlackhole: publish root operand stack
Loading

Possibly related PRs

Poem

A rabbit checks the stack at night,
Mirrors settle, slots align right.
Enter hops in, Exit hops out,
Escapes now carry stacks about.
Blackholes wake with state in sight.

🚥 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 and concisely identifies all three defects addressed by the pull request.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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 nbody

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 af8f4d8).
Updated: 2026-08-05T08:38:10.846Z

Files in the reviewed diff
majit/majit-metainterp/src/pyjitpl.rs
pyre/bench/synth/divmod_long_int_pair.py
pyre/check.py
pyre/extra_tests/parity_tests/divmod_long_int_jit.py
pyre/pyre-interpreter/src/builtins.rs
pyre/pyre-interpreter/src/executioncontext.rs
pyre/pyre-interpreter/src/jit_fnaddr.rs
pyre/pyre-interpreter/src/module/sys/vm.rs
pyre/pyre-interpreter/src/objspace/descroperation.rs
pyre/pyre-interpreter/src/typedef.rs
pyre/pyre-jit-trace/src/descr.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/bridge_subwalk.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/resume_snapshot.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/vstack_mirror.rs
pyre/pyre-jit-trace/src/trace.rs
pyre/pyre-jit/src/call_jit.rs
pyre/pyre-jit/src/eval.rs
pyre/pyre-object/src/longobject.rs
pyre/pyre-object/src/rbigint.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/specialize.rs:6729 ↔ pypy/objspace/std/longobject.py:451-457; rpython/rlib/rbigint.py:1049-1080 — Long/int divmod is now explicitly declined to opaque bh_call_fn handling ("if !is_exact_int(lhs_obj) || !is_exact_int(rhs_obj) { return Ok(None); }"). PyPy dispatches this shape to _int_divmod, which calls elidable rbigint.int_divmod; the patch removes the corresponding pure two-result trace lowering. This preserves Python results via fallback but regresses JIT structural/optimization parity.

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

  • pyre/pyre-interpreter/src/module/sys/vm.rs:602-618 ↔ pypy/module/sys/vm.py:43-55 — Pyre calls gettopframe() and then explicitly force_frame(current) before returning _getframe; PyPy’s source only calls gettopframe_nohidden() and marks the frame escaped. The extra virtualizable forces turn a traced constant-depth _getframe into a residual/vable-escape path.

  • pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs:3172-3174 ↔ rpython/jit/metainterp/pyjitpl.py:2530-2556 — Carrier exception resume remains opt-in ("PYRE_CARRIER_EXC_RESUME"). PyPy always walks and pops its complete framestack until it finds the catching frame; Pyre therefore still declines bridge compilation for exception handlers inside an inlined callee.

4. Structural adaptations

  • pyre/pyre-interpreter/src/executioncontext.rs:47-50 ↔ pypy/interpreter/pyframe.py:540-553 — Pyre uses an explicit Rust force_frame hook before reading fastlocals. PyPy obtains the same materialization through translated virtualizable field-access machinery. This is a Rust/source-translation adaptation, not a Python-visible semantic difference.

  • pyre/pyre-jit-trace/src/jitcode_dispatch/vstack_mirror.rs:227-239 ↔ pypy/interpreter/pyopcode.py:1337-1348 — Pyre models CPython-compatible LOAD_SPECIAL stack effects for context-manager entry/exit, whereas local PyPy source uses BEFORE_WITH. This is an opcode/compiler-version adaptation.

…nd-stack slots

The GUARD_NOT_FORCED vable-snapshot overlay projected every live Ref register
named by the per-PC pcdep color->slot map into virtualizable_boxes[nvs + slot],
slots below nlocals included. That map labels a slot per program point instead
of binding a register to it, and the register allocator reuses a local's color
for unrelated SSA temps, so a local slot could be snapshotted holding an
unrelated box. In asyncio _run_once the recorded resume section named local 0
as a ConstPtr to the frame's own code object; forcing the virtualizable wrote
that code object into locals_cells_stack_w[0], and the debug arm's finally
raised AttributeError: 'code' object has no attribute '_current_handle'.

Skip slots below nlocals. The residual call's result lands on the operand
stack, and a local's slot is maintained in lockstep by the shadow.

Assisted-by: Claude
…adopt

`try_adopt_single_frame_blackhole` published the frame's root operand stack
only for the `TraceTooLong` and `WalkAbort` legs, on the stated premise that
the vable-escape leg "resumes immediately after one forcing residual and keeps
its narrower resume-marker image".  The escape latch matched that premise with
`mirror_stack: None`.

A blackhole position ring in jitcode `_run_once`, seeded by the escape leg at
resume pc 4180, records the resume running to 4668, branching through 5802 and
5091 to 5157, and re-entering the loop header at 3319, where the
`getarrayitem_vable_r` at 3322 reloads an operand from the virtualizable's
`locals_cells_stack_w`.  With no stack published that slot is NULL, and the
`residual_call_r_r` at 3330 calls `jit_next(NULL)`.

Publish the root stack for this leg as well.  Like `WalkAbort` it stops INSIDE
an opcode, where a root walk never wrote the snapshot array, so the source is
the walker's OpRef mirror; the escape latch now captures that mirror through
`capture_vstack_mirror_image`, the same helper `latch_abort_blackhole`'s
single-frame arm uses under the identical `framestack.is_empty() &&
!inline_subwalk` precondition.

Measured with an asyncio Runner.close/shutdown_asyncgens harness over a
9-module `test.test_asyncio` warmup, A/B'd inside one binary through an env
hatch: 3/6 runs SIGSEGV without the publish, 0/6 with it.  `check.py --backend
dynasm` 377/377.

Assisted-by: Claude
`classify_vstack_opcode` had no arm for `LOAD_SPECIAL`, so every `with` /
`async with` body reached the `Unmodeled` fallthrough and latched
`vstack_valid = false` for the rest of the walk.  A later branch guard with a
kept operand stack deeper than one slot then aborts
(`BranchGuardKeptStackUnsupported`), and that abort is outside
`DispatchError::leaves_complete_image`, so no blackhole adoption leg covers it:
the walk ends uncommitted and the interpreter replays a region whose residual
calls already ran concretely.

The opcode pops the context-manager object at `prev_depth - 1` and pushes the
special method and the call self/NULL slot upward from that position.  Both
pushed values go through `emit_pushvalue_ref!`, so the virtualizable shadow is
authoritative for each: that is `MultiResultFromShadow`, the same shape
`UNPACK_SEQUENCE` uses.  `AEnter` / `AExit` keep the `Unmodeled` fallthrough.

Measured on an asyncio harness (400 rounds, one binary, env-gated A/B):
mirror invalidations 5 -> 0, `BranchGuardKeptStackUnsupported` declines 5 -> 0,
uncommitted walks with a non-zero effect count 11 -> 6.

Assisted-by: Claude
@youknowone
youknowone merged commit dbdb30b into main Aug 5, 2026
7 of 8 checks passed
@youknowone
youknowone deleted the nbody branch August 5, 2026 08:35
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

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

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

commit_leg == WalkEndCommitLeg::TraceTooLong || commit_leg == WalkEndCommitLeg::WalkAbort;
let publishes_root_stack = commit_leg == WalkEndCommitLeg::TraceTooLong
|| commit_leg == WalkEndCommitLeg::WalkAbort
|| commit_leg == WalkEndCommitLeg::VableEscape;

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 Do not replay vable escapes when the mirror is absent

For WalkEndCommitLeg::VableEscape, this new requirement makes adoption fail whenever the latch's mirror_stack is None (for example after an unmodeled stack opcode has already set ctx.vstack_valid = false, or a live stack slot cannot be resolved). That latch is built after the forcing residual has already executed, and in the live-heap/reentrant cases the escape PC has been canceled, so returning false here drops into the legacy replay path and re-runs the residual side effects. Please preflight a complete mirror before choosing this leg, or keep recording/blackhole-forward instead of declining after the residual has run.

Useful? React with 👍 / 👎.

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