Skip to content

jit: stop recover_ref_value answering the NO_CONCRETE sentinel, and record why filtering it downstream is not the follow-up - #1380

Merged
youknowone merged 3 commits into
mainfrom
fbw
Aug 20, 2026
Merged

jit: stop recover_ref_value answering the NO_CONCRETE sentinel, and record why filtering it downstream is not the follow-up#1380
youknowone merged 3 commits into
mainfrom
fbw

Conversation

@youknowone

@youknowone youknowone commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Three commits on top of origin/main. One behaviour change — the Codex review finding on #1364, which merged before the fix was written — and two comments recording measured refutations.

The fix

recover_ref_value no longer answers the NO_CONCRETE sentinel as if it were an address. It returned whatever concrete_of_opref held, including the Value::Ref(GcRef::NO_CONCRETE) stamp heapcache_ops' materialized-array walk writes over a load it could not replay. The two blackhole-frame ref fills that reach this function directly match on the Value::Ref variant alone, so usize::MAX - 1 was written into a resumed frame's ref bank as an object address: capture_inline_parent_blackhole (the site Codex flagged) and build_single_frame_miframe. It now answers unresolved, which is what those sites already do for a color whose register holds no box — the image is declined and the outer flush falls back to the legacy replay. The receiver guard inside the same function checked NULL and the all-ones tombstone by hand and missed the sentinel; it now calls live_gc_ptr, the one predicate that names all three. Pinned by a test shown to fail on the unfixed function, with a positive control.

The deliberately conservative variant was chosen: return None rather than falling through to the producer-chain replay. Falling through could produce a different concrete value; answering unresolved can only turn a poisoned answer into a decline.

Two comments, and why they are worth their diff

Both record work whose result was "do not change the code" — the kind of finding that gets rediscovered and re-attempted if it is not written where the next reader hits it.

lookup_opref_concrete: filtering the sentinel is not the follow-up fix it looks like. Three more sites pair the stamp table with recover_ref_value in the order lookup_opref_concrete(..).or_else(|| recover_ref_value(..)), and Some(_).or_else(f) never calls f, so the stamp short-circuits ahead of the new guard. Two others accept the sentinel through a value.0 != 0 arm that excludes NULL but not usize::MAX - 1. The obvious patch — filter it at those sites — was written and then refuted: every one of them ends in a fallback tail (.or(from_register), .or(from_shadow), _ => sym.live_vable_frame_addr()), so rejecting the sentinel does not decline the image, it promotes the next fallback — a different concrete address into a blackhole ref bank or, through store_live_frame_array_slot, into a live frame's locals array behind a write barrier. vable_ops.rs already records that re-reading the shadow it would promote "can return a stale concrete value from a prior loop iteration". Wrong data, not a refusal. The real question is per-site: what should each answer for an unresolved box? Filed as a follow-up; reachability there is inferred, never observed, and the first step is a measurement, not a patch.

Related, and not changed here: the literal worry that started this — that the sentinel could reach vable_setfield — is inert. That function does let _ = concrete; on one leg and concrete.unwrap_or(Value::Ref(GcRef::NO_CONCRETE)) on the other, so None and Some(sentinel) are indistinguishable inputs there.

finish_and_compile: the second loops_compiled is not a double count. exception_bridge_traceback_head reads loops_compiled=2 with a single [jit] compiled loop at key= line, which invites exactly that conclusion. Five sites bump the counter, one per Backend::compile_loop caller, and only compile_loop_body logs that line. The second increment is this site, reached only from the bridge.is_none() arm, and upstream counts a root FINISH trace with no LABEL as a loop too — ResumeFromInterpDescr.compile_and_attach (compile.py:1006-1017) mints its own JitCellToken and calls send_loop_to_backend(.., "entry bridge", ..). The backend-side total_compiled_loops, bumped at the model.py:297 point, independently reads 2 on that fixture: two CompiledLoopTokens, two green keys, two code buffers.

Verification

cargo fmt --all clean; cargo check --all --all-targets --features dynasm rc=0 (the target set a rebase can break without check.py ever seeing it); full LLBC re-extract; then pyre/check.pydynasm 443/443, cranelift 443/443, wasm 436/436, 3/3 backend runs.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TBNBmnBveAQFbqRtrvQVyQ

… value

`recover_ref_value` returned whatever `concrete_of_opref` held, including
`Value::Ref(GcRef::NO_CONCRETE)` -- the stamp `heapcache_ops` writes over a load
the walk could not replay. The two blackhole-frame ref fills that reach this
function directly match on the `Value::Ref` variant alone, so `usize::MAX - 1`
was written into a resumed frame's ref bank as if it were an object address:
`capture_inline_parent_blackhole` (jitcode_dispatch/resume_snapshot.rs) and
`build_single_frame_miframe` (jitcode_dispatch/residual_call.rs).

It now answers unresolved, which is what those sites already do for a color
whose register holds no box: the image is declined and the outer flush falls
back to the legacy replay. The receiver guard in the same function checked NULL
and the all-ones tombstone by hand and missed the sentinel; it now calls
`live_gc_ptr`, the one place that names all three.

Sites that read the recorder's stamp table first and only then fall back to this
function are NOT covered, because `Some(_).or_else(f)` never calls `f`:
`fill_trace_too_long_register_banks` and the two `vable_ops` resolvers still see
the sentinel.

The test fails on the unfixed function (returns `Some(Ref(NO_CONCRETE))`) and
carries a positive control so the assertion cannot pass by the stamp failing to
land.

Reported by Codex review on #1364.

Assisted-by: Claude
…stamp is not the fix

The recorder's stamp table reports `Value::Ref(GcRef::NO_CONCRETE)` verbatim --
`heapcache_ops`' materialized-array walk writes it over a load it could not
replay. `recover_ref_value` rejects the sentinel; this method does not, because
"never stamped" and "stamped unresolved" are different states and only the
caller knows which one it can act on.

Three sites pair the two stamp-first, so `Some(_).or_else(f)` short-circuits
ahead of that guard: `vable_value_concrete`, `current_inline_vable_target` and
`fill_trace_too_long_register_banks`. Two more accept the sentinel through a
`value.0 != 0` arm, which excludes NULL but not `usize::MAX - 1`.

Filtering it at those sites was proposed and does not hold: each one ends in a
fallback tail, so rejecting the sentinel does not decline the image, it promotes
`from_register` / `from_shadow` / `live_vable_frame_addr` -- a different concrete
address landing in a blackhole frame's ref bank or, through
`store_live_frame_array_slot`, in a live frame's locals array behind a write
barrier. `vable_ops.rs` already records that re-reading the shadow it would
promote can return a stale concrete value from a prior loop iteration.

Comment only; no behaviour change.

Assisted-by: Claude
`exception_bridge_traceback_head` reports `loops_compiled=2` with a single
`[jit] compiled loop at key=` line, which reads as a double count. It is not.
Five sites bump the counter, one per `Backend::compile_loop` caller, and only
`compile_loop_body` logs that line. The second increment on that fixture is this
site, reached only from the `bridge.is_none()` arm of
`compile_finish_from_active_session`, and upstream counts a root FINISH trace
with no LABEL as a loop as well: `ResumeFromInterpDescr.compile_and_attach`
(compile.py:1006-1017) mints its own JitCellToken and calls
`send_loop_to_backend(.., "entry bridge", ..)`. The backend-side
`total_compiled_loops`, bumped at the `model.py:297` point, also reads 2 there --
two CompiledLoopTokens, two green keys.

Comment only; no behaviour change.

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Aug 20, 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: 4 minutes

Limit details: You’ve used all 2 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f456c13c-07e1-40d5-9743-6b77733599f1

📥 Commits

Reviewing files that changed from the base of the PR and between e4fb0a3 and 79837e2.

📒 Files selected for processing (2)
  • majit/majit-metainterp/src/pyjitpl.rs
  • majit/majit-metainterp/src/trace_ctx.rs

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: 79837e2845

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

// `bridge.is_some()` to `compile_trace_finish`), and a root
// trace that ends in FINISH with no LABEL attaches upstream
// through `ResumeFromInterpDescr.compile_and_attach`
// (compile.py:1006-1017), which mints its own JitCellToken and

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 Remove the redundant upstream line-range citation

This comment cites compile.py:1006-1017 even though the preceding line already identifies ResumeFromInterpDescr.compile_and_attach. Repository guidance requires upstream citations by symbol and permits line numbers only where no symbol pins the claim, because numeric references silently rot as upstream changes; keep the symbol reference and remove the range.

AGENTS.md reference: AGENTS.md:L188-L191

Useful? React with 👍 / 👎.

@github-actions

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 79837e2).
Updated: 2026-08-20T13:45:18.298Z

Files in the reviewed diff
majit/majit-metainterp/src/pyjitpl.rs
majit/majit-metainterp/src/trace_ctx.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

  • majit/majit-metainterp/src/trace_ctx.rs:3245 ↔ rpython/jit/metainterp/history.py:703 — Pyre rejects its Rust-only GcRef::NO_CONCRETE placeholder before recovering/dereferencing a reference. PyPy’s RefFrontendOp stores an actual executed result in _resref; it has no equivalent “unknown pointer” value. This is a fundamental representation adaptation and preserves the upstream outcome of not treating an unavailable concrete value as an object.

@youknowone
youknowone merged commit 1f959ee into main Aug 20, 2026
17 checks passed
@youknowone
youknowone deleted the fbw branch August 20, 2026 14:20
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