Skip to content

Eleven defect fixes: a mapdict __dict__ materialisation that invalidated the JIT's shape guard, plus locals() staleness, GC pinning, and narrowing-order bugs - #1075

Merged
youknowone merged 13 commits into
mainfrom
wasm-jit
Aug 6, 2026

Conversation

@youknowone

@youknowone youknowone commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Eleven independent defect fixes, rebased onto current main. Each commit is
self-contained and carries its own upstream citation and measurement; the
grouping is chronological, not thematic.

The one with the largest blast radius

mapdict: implement MapdictDictSupport's get/setdictvalue instead of
routing through getdict.

baseobjspace::getdictvalue/setdictvalue carried only the W_Root defaults
(baseobjspace.py:46-50, 52-57) and applied them to every receiver, resolving
the instance dict through getdict_backing -> getdict -> _obj_getdict. For
a mapdict carrier that materialises the ("dict", SPECIAL) wrapper, which adds
an attribute to the instance's map — so an ordinary self.a = a moved the
receiver from Plain("a") to Plain("dict", SPECIAL, back=Plain("a")).

mapdict.py:846-850 MapdictDictSupport overrides both for a mapdict carrier and
reads/writes the map directly. The JIT bakes the pre-materialisation map into
the GuardValue that walker_guard_mapdict_instance_shape emits, so the shape
change made that guard fail on every entry until a bridge attached.

Re-recorded baselines — guard_failures, and bridges_compiled where it moved:

fixture before after bridges
mapdict_frozen_unboxing_fold 203/203/404 2/2/2 1/1/2 -> 0
sre_pattern_methods 2291/2291/2292 1012/1012/1013 11 -> 5
sre_wasm_min 1849 803 8 -> 4
sre_wasm_min1 803 603 4 -> 3
polymorphic_binary_receiver 1045 788 4 -> 3
pickle_terminal_raise_resume 463/656/464 338/338/339
pickle_ctor_args (cranelift) 201 1 1 -> 0
inline_callee_constructs_object (cranelift) 201 1 1 -> 0
foriter_inplace_immutable (cranelift) 202 1 1 -> 0

The three cranelift-only rows are a selection effect, not a backend-specific
cause: PyFrame::store_attr_cached (eval.rs:4630) ports pyopcode.py:920
if not jit.we_are_jitted(): and skips the mapdict cache when jitted, falling
through to object_setattr -> setdictvalue; majit-backend-cranelift's
compiler.rs:7424 is the only setter of that flag in the tree. Filed as a
separate follow-up.

__dict__ observable behaviour was checked against python3.14 on a 13-case
fixture (materialisation, vars(), aliasing, __dict__ assignment, del,
__slots__ rejection, 120-attribute devolve, surrogate-named attribute):
byte-identical.

The rest

  • builtins — force the frame behind locals()/vars()/dir(), and fold
    the zero-argument form in the walker. topframe_for_locals forced the vref
    but never called force_frame, so inside a compiled loop locals() returned
    the right keys with values thousands of iterations stale.
  • jit — name ABORT_ESCAPE as the reason for the vable-escape walker
    decline (pyjitpl.py:3389-3390). The escape was being tallied in the counter
    jitprof.rs labels bridge_or_generic. Reclassifies a tally; recovers no
    trace, and no .jitstats field records an abrt_* counter.
  • signal/zlib — range-check the signal number before narrowing
    ((1 << 32) | SIGINT truncated to SIGINT and passed the check), and route
    Compress.flush's mode through c_int_w as interp_zlib.py:196 spells it.
  • type_methods — pin list.sort's receiver and key across the reverse=
    __bool__, which runs Python and can collect while args is an unupdated
    native copy. Adds a gc_stress case.
  • listobject — hold the stripe lock across w_list_init_items' storage
    replacement, matching its destructive twin w_list_clear.
  • launch_env — resolve utf8_mode from the effective LC_CTYPE rather
    than the raw env cascade, so a locale the C library cannot install
    (LC_ALL=xx_YY.invalid) no longer reads as a named locale.
  • posix — narrow pathconf/fpathconf/sysconf names with
    i32::try_from and raise OverflowError, instead of truncating
    os.pathconf(fd, 2**40) to name 0 and reporting the resulting EINVAL.
  • math — make factorial's __index__ presence check non-binding.
    app_math.py uses '__index__' not in dir(n); the port used lookup_special,
    binding the descriptor twice per call.
  • display — spend a recursion unit for the frameless
    BaseException.descr_str re-entry, so a self-referential e.args = (e,)
    raises RecursionError instead of running until killed.
  • stdio — settle fd 1 after a write when -u/PYTHONUNBUFFERED is set.
    The stdout write builtin reached std::io::stdout(), a LineWriter, so the
    buffering flags never applied to it.

Verification

check.py, all three backends, on the rebased tree:

backend result
dynasm 389/389, all passed
cranelift 388 passed, 1 failed
wasm 381 passed, 4 failed

The single cranelift failure is the pre-existing pickle_ctor_args perf gate
(exec 0.97s > pypy 0.02s, ratio 44.7x > gate 36x), already tracked separately;
it is a ratio over a 0.02s pypy denominator and is not affected by anything here.

Four .wasm.jitstats fixtures read +-1/+-2 guard_failures against their
committed baselines. They are main's own reds, not this branch's, and are
deliberately left un-re-recorded. The base's own pyre/check.py (ubuntu-24.04)
job — the only leg that runs the wasm backend — produces the same four numbers
at fcf997da0e0, one commit behind this branch's base, with none of these
commits present:

fixture baseline base's own ubuntu CI here
closure_per_call 470 468 468
exception_traceback_frame_lineno 820 819 819
gc_iterator_source_drop 613 614 614
recursive_call_frame_relocation 649 648 648

Identical on both hosts, so this is not host wobble either. Recording them here
would bless a pre-existing main regression under an unrelated change. This
branch additionally touches no majit/ crate at all — every source change in it
is backend-neutral — and dynasm and cranelift show no movement on those four.

Summary by CodeRabbit

  • Bug Fixes
    • locals(), vars(), and dir() now consistently reflect current loop-local values.
    • Deeply recursive string conversions raise RecursionError instead of risking stack overflow.
    • Unbuffered output now flushes reliably.
    • Improved validation prevents silent integer truncation in signals, system configuration, paths, and compression APIs.
    • Fixed edge cases in math.factorial(), list sorting, dictionary-backed objects, and locale UTF-8 detection.
    • Improved list sorting reliability during garbage collection.
  • Tests
    • Added regression coverage for frame-local inspection and garbage collection during sorting.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

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

Next review available in: 38 minutes

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?

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: 046414ea-9c39-4d8b-ba9e-bab0c579b8a0

📥 Commits

Reviewing files that changed from the base of the PR and between fd709f2 and b8ba6a9.

📒 Files selected for processing (46)
  • pyre/bench/synth/foriter_inplace_immutable.cranelift.jitstats
  • pyre/bench/synth/inline_callee_constructs_object.cranelift.jitstats
  • pyre/bench/synth/locals_forced_frame.cranelift.jitstats
  • pyre/bench/synth/locals_forced_frame.dynasm.jitstats
  • pyre/bench/synth/locals_forced_frame.py
  • pyre/bench/synth/locals_forced_frame.wasm.jitstats
  • pyre/bench/synth/mapdict_frozen_unboxing_fold.cranelift.jitstats
  • pyre/bench/synth/mapdict_frozen_unboxing_fold.dynasm.jitstats
  • pyre/bench/synth/mapdict_frozen_unboxing_fold.wasm.jitstats
  • pyre/bench/synth/pickle_terminal_raise_resume.cranelift.jitstats
  • pyre/bench/synth/pickle_terminal_raise_resume.dynasm.jitstats
  • pyre/bench/synth/pickle_terminal_raise_resume.wasm.jitstats
  • pyre/bench/synth/polymorphic_binary_receiver.cranelift.jitstats
  • pyre/bench/synth/polymorphic_binary_receiver.dynasm.jitstats
  • pyre/bench/synth/polymorphic_binary_receiver.wasm.jitstats
  • pyre/bench/synth/sre_pattern_methods.cranelift.jitstats
  • pyre/bench/synth/sre_pattern_methods.dynasm.jitstats
  • pyre/bench/synth/sre_pattern_methods.wasm.jitstats
  • pyre/bench/synth/sre_wasm_min.cranelift.jitstats
  • pyre/bench/synth/sre_wasm_min.dynasm.jitstats
  • pyre/bench/synth/sre_wasm_min.wasm.jitstats
  • pyre/bench/synth/sre_wasm_min1.cranelift.jitstats
  • pyre/bench/synth/sre_wasm_min1.dynasm.jitstats
  • pyre/bench/synth/sre_wasm_min1.wasm.jitstats
  • pyre/pyre-interpreter/src/baseobjspace.rs
  • pyre/pyre-interpreter/src/builtins.rs
  • pyre/pyre-interpreter/src/display.rs
  • pyre/pyre-interpreter/src/executioncontext.rs
  • pyre/pyre-interpreter/src/host_seam.rs
  • pyre/pyre-interpreter/src/launch_env.rs
  • pyre/pyre-interpreter/src/lib.rs
  • pyre/pyre-interpreter/src/module/math/interp_math.rs
  • pyre/pyre-interpreter/src/module/posix/interp_posix.rs
  • pyre/pyre-interpreter/src/module/signal/interp_signal.rs
  • pyre/pyre-interpreter/src/module/sys/vm.rs
  • pyre/pyre-interpreter/src/module/zlib/mod.rs
  • pyre/pyre-interpreter/src/objspace/std/mapdict.rs
  • pyre/pyre-interpreter/src/pyframe.rs
  • pyre/pyre-interpreter/src/type_methods.rs
  • pyre/pyre-interpreter/src/typedef.rs
  • pyre/pyre-jit-trace/src/driver.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs
  • pyre/pyre-jit-trace/src/state.rs
  • pyre/pyre-jit/tests/gc_stress.rs
  • pyre/pyre-object/src/listobject.rs

Walkthrough

The PR adds JIT specialization for locals(), vars(), and dir(), updates frame materialization, and adds forced-frame benchmarks. It also corrects interpreter handling for mapdict access, integer conversion, output flushing, recursion, locale detection, and list storage.

Changes

JIT frame-local builtins

Layer / File(s) Summary
Frame-local contracts
pyre/pyre-interpreter/src/pyframe.rs, pyre/pyre-interpreter/src/builtins.rs, pyre/pyre-interpreter/src/executioncontext.rs, pyre/pyre-interpreter/src/typedef.rs
Plain fastlocals detection, frame forcing, locals-dictionary helpers, and frame-materialization documentation were added or updated.
Builtin locals API
pyre/pyre-interpreter/src/builtins.rs
Builtin identity predicates and shared locals-key sorting helpers were added.
Walker specialization and escape handling
pyre/pyre-jit-trace/src/jitcode_dispatch/*, pyre/pyre-jit-trace/src/state.rs
The JIT specializes zero-argument locals(), vars(), and dir() calls and records virtualizable escape aborts.
Forced-frame benchmark
pyre/bench/synth/locals_forced_frame*
The benchmark checks current loop-local values through locals() and vars() and records backend statistics.

Interpreter runtime corrections

Layer / File(s) Summary
Mapdict dictionary access
pyre/pyre-interpreter/src/baseobjspace.rs, pyre/pyre-interpreter/src/objspace/std/mapdict.rs
Mapdict-backed attribute reads and writes now use instance-node storage directly.
Checked C integer conversions
pyre/pyre-interpreter/src/baseobjspace.rs, pyre/pyre-interpreter/src/module/{math,posix,signal,zlib}/*
Numeric arguments remain wide until range validation and checked narrowing.
Output and locale handling
pyre/pyre-interpreter/src/{host_seam.rs,lib.rs,launch_env.rs}, pyre/pyre-interpreter/src/module/sys/vm.rs
Unbuffered output flushes after writes, and supported host builds use effective LC_CTYPE detection.
Native string recursion protection
pyre/pyre-interpreter/src/display.rs
Native string and exception formatting now checks recursion depth.
List sorting and storage safety
pyre/pyre-interpreter/src/type_methods.rs, pyre/pyre-object/src/listobject.rs, pyre/pyre-jit/tests/gc_stress.rs
List sorting roots callback objects, storage replacement is synchronized, and GC-stress coverage was added.

JIT benchmark statistics

Layer / File(s) Summary
Benchmark baseline updates
pyre/bench/synth/*.jitstats
Synthetic benchmark baselines now include field-position counters and revised bridge, guard-failure, and loop totals.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant JIT residual dispatcher
  participant locals specialization
  participant frame locals helpers
  participant dir names helper
  JIT residual dispatcher->>locals specialization: recognize zero-argument locals(), vars(), or dir()
  locals specialization->>frame locals helpers: read guarded virtualized fastlocals
  frame locals helpers-->>locals specialization: build locals mapping
  locals specialization->>dir names helper: sort mapping keys for dir()
  dir names helper-->>JIT residual dispatcher: return names or null
Loading

Possibly related issues

  • youknowone/pyre#205: The issue covers JIT trace correctness for forced-frame locals() and vars() behavior.

Possibly related PRs

Poem

A rabbit checks the locals in flight,
While guards now count the path just right.
Frames wake, lists hold tight,
Streams flush into the night.
“Hop,” says the hare, “the JIT is bright!”

🚥 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 primary defect fixes, including mapdict materialization, locals staleness, GC pinning, and narrowing-order bugs.
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.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit b8ba6a9).
Updated: 2026-08-06T10:26:49.528Z

Files in the reviewed diff
pyre/bench/synth/locals_forced_frame.py
pyre/pyre-interpreter/src/baseobjspace.rs
pyre/pyre-interpreter/src/builtins.rs
pyre/pyre-interpreter/src/display.rs
pyre/pyre-interpreter/src/executioncontext.rs
pyre/pyre-interpreter/src/host_seam.rs
pyre/pyre-interpreter/src/launch_env.rs
pyre/pyre-interpreter/src/lib.rs
pyre/pyre-interpreter/src/module/math/interp_math.rs
pyre/pyre-interpreter/src/module/posix/interp_posix.rs
pyre/pyre-interpreter/src/module/signal/interp_signal.rs
pyre/pyre-interpreter/src/module/sys/vm.rs
pyre/pyre-interpreter/src/module/zlib/mod.rs
pyre/pyre-interpreter/src/objspace/std/mapdict.rs
pyre/pyre-interpreter/src/pyframe.rs
pyre/pyre-interpreter/src/type_methods.rs
pyre/pyre-interpreter/src/typedef.rs
pyre/pyre-jit-trace/src/driver.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs
pyre/pyre-jit-trace/src/state.rs
pyre/pyre-jit/tests/gc_stress.rs
pyre/pyre-object/src/listobject.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:6569,6696 ↔ pypy/interpreter/pyframe.py:539,549: the new MAX_MODELLED_FASTLOCALS = 32 declines the traced fast2locals expansion for wider functions, whereas PyPy’s @jit.unroll_safe loop has no such semantic-size cutoff. This preserves interpreter results through the residual fallback, but loses JIT trace parity for functions with more than 32 locals.

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

  • pyre/pyre-interpreter/src/module/math/interp_math.rs:755 ↔ pypy/module/math/app_math.py:14: PyPy literally tests "'__index__' not in dir(n)"; therefore a user-defined __dir__ can affect factorial’s initial acceptance or raise. Pyre performs a type-MRO lookup instead. The patch changes lookup_special to lookup (an improvement because it no longer binds __index__), but neither main nor the patched code invokes dir(n), so the core divergence predates this patch.

  • pyre/pyre-interpreter/src/type_methods.rs:734-736 ↔ pypy/objspace/std/listobject.py:808-811: Pyre converts list.sort(reverse=…) using truthiness (is_true, allowing user __bool__); PyPy’s @unwrap_spec(reverse=int) requires integer conversion. This was already present in upstream/main; the new rooting change safely supports Pyre’s existing behavior but does not make it PyPy-equivalent.

4. Structural adaptations

  • pyre/pyre-interpreter/src/pyframe.rs:4425-4429 ↔ pypy/interpreter/pyframe.py:542-583: Pyre deliberately builds a fresh locals dictionary for optimized frames, matching Python 3.14/PEP 667 snapshot semantics; PyPy caches and returns w_locals. This is a Python-version adaptation, not an incorrect port.

  • pyre/pyre-interpreter/src/executioncontext.rs:34-65 ↔ rpython/rtyper/rvirtualizable.py:49-53: Pyre explicitly invokes the frame-force hook before application-visible fastlocals reads because its current Rust rtyper does not synthesize RPython’s jit_force_virtualizable field-access injection. This is a translator/runtime structural adaptation.

  • pyre/pyre-interpreter/src/type_methods.rs:721-740 ↔ pypy/objspace/std/listobject.py:808-811: Rooting the list receiver and key callable across reverse conversion is required by Pyre’s moving-GC, free-threaded/native-call boundary. PyPy’s RPython object references and GIL-era execution model do not require this Rust shadow-stack treatment.

  • pyre/pyre-object/src/listobject.rs:1705-1712 ↔ pypy/objspace/std/listobject.py:873-879: The added list stripe lock prevents concurrent observation of a half-reinstalled list representation. PyPy performs this replacement under its GIL; the lock is a free-threading structural adaptation.

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

🤖 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-interpreter/src/builtins.rs`:
- Around line 11433-11442: Update jit_dir_names_from_locals so its Err(_) branch
drains the pending call error from PENDING_CALL_ERROR before returning PY_NULL,
matching the error-handling behavior required for Result failures from
dir_names_from_locals_mapping.

In `@pyre/pyre-interpreter/src/module/signal/interp_signal.rs`:
- Around line 181-188: Update the remaining host signal APIs—raise_signal,
strsignal, siginterrupt, pthread_kill, pthread_sigmask, and pidfd_send_signal—to
call check_signum_in_range on each Python-provided signal argument before
narrowing it to i32 or passing it to host calls. Reuse signum_arg for the
unwrapped value where appropriate, preserving rejection of values outside the
valid signal range and preventing low-bit aliases such as (1 << 32) | SIGINT.

In `@pyre/pyre-object/src/listobject.rs`:
- Around line 1705-1712: Update the lock acquisition in the list mutation path
around w_list_lock so its stripe identity comes from stable list-owned state
rather than the pre-block relocatable obj address. Ensure callers use the same
identity after before_external_block relocation, while preserving the post-lock
shadow_stack_get reload and existing lock coverage.
🪄 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: c78b9b73-df69-4c7e-8fc2-544992f7c8bf

📥 Commits

Reviewing files that changed from the base of the PR and between 5eefca2 and 85fa2df.

📒 Files selected for processing (46)
  • pyre/bench/synth/foriter_inplace_immutable.cranelift.jitstats
  • pyre/bench/synth/inline_callee_constructs_object.cranelift.jitstats
  • pyre/bench/synth/locals_forced_frame.cranelift.jitstats
  • pyre/bench/synth/locals_forced_frame.dynasm.jitstats
  • pyre/bench/synth/locals_forced_frame.py
  • pyre/bench/synth/locals_forced_frame.wasm.jitstats
  • pyre/bench/synth/mapdict_frozen_unboxing_fold.cranelift.jitstats
  • pyre/bench/synth/mapdict_frozen_unboxing_fold.dynasm.jitstats
  • pyre/bench/synth/mapdict_frozen_unboxing_fold.wasm.jitstats
  • pyre/bench/synth/pickle_ctor_args.cranelift.jitstats
  • pyre/bench/synth/pickle_terminal_raise_resume.cranelift.jitstats
  • pyre/bench/synth/pickle_terminal_raise_resume.dynasm.jitstats
  • pyre/bench/synth/pickle_terminal_raise_resume.wasm.jitstats
  • pyre/bench/synth/polymorphic_binary_receiver.cranelift.jitstats
  • pyre/bench/synth/polymorphic_binary_receiver.dynasm.jitstats
  • pyre/bench/synth/polymorphic_binary_receiver.wasm.jitstats
  • pyre/bench/synth/sre_pattern_methods.cranelift.jitstats
  • pyre/bench/synth/sre_pattern_methods.dynasm.jitstats
  • pyre/bench/synth/sre_pattern_methods.wasm.jitstats
  • pyre/bench/synth/sre_wasm_min.cranelift.jitstats
  • pyre/bench/synth/sre_wasm_min.dynasm.jitstats
  • pyre/bench/synth/sre_wasm_min.wasm.jitstats
  • pyre/bench/synth/sre_wasm_min1.cranelift.jitstats
  • pyre/bench/synth/sre_wasm_min1.dynasm.jitstats
  • pyre/bench/synth/sre_wasm_min1.wasm.jitstats
  • pyre/pyre-interpreter/src/baseobjspace.rs
  • pyre/pyre-interpreter/src/builtins.rs
  • pyre/pyre-interpreter/src/display.rs
  • pyre/pyre-interpreter/src/executioncontext.rs
  • pyre/pyre-interpreter/src/host_seam.rs
  • pyre/pyre-interpreter/src/launch_env.rs
  • pyre/pyre-interpreter/src/lib.rs
  • pyre/pyre-interpreter/src/module/math/interp_math.rs
  • pyre/pyre-interpreter/src/module/posix/interp_posix.rs
  • pyre/pyre-interpreter/src/module/signal/interp_signal.rs
  • pyre/pyre-interpreter/src/module/sys/vm.rs
  • pyre/pyre-interpreter/src/module/zlib/mod.rs
  • pyre/pyre-interpreter/src/objspace/std/mapdict.rs
  • pyre/pyre-interpreter/src/pyframe.rs
  • pyre/pyre-interpreter/src/type_methods.rs
  • pyre/pyre-interpreter/src/typedef.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs
  • pyre/pyre-jit-trace/src/state.rs
  • pyre/pyre-jit/tests/gc_stress.rs
  • pyre/pyre-object/src/listobject.rs

Comment on lines +11433 to +11442
pub extern "C" fn jit_dir_names_from_locals(mapping: i64) -> i64 {
let _roots = pyre_object::gc_roots::push_roots();
let mapping_slot = pyre_object::gc_roots::shadow_stack_len();
pyre_object::gc_roots::pin_root(mapping as PyObjectRef);
let mapping = pyre_object::gc_roots::shadow_stack_get(mapping_slot);
match dir_names_from_locals_mapping(mapping) {
Ok(names) => names as i64,
Err(_) => pyre_object::PY_NULL as i64,
}
}

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 | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether the error paths reachable from dir_names_from_locals_mapping publish state beyond the returned PyError.
set -euo pipefail

rg -n -C 6 'pub fn call_and_check' pyre/pyre-interpreter/src
rg -n -C 4 'set_call_error|take_call_error|BH_LAST_EXC_VALUE|store_exception' pyre/pyre-interpreter/src/call.rs
ast-grep run --pattern 'fn builtin_sorted($$$) { $$$ }' --lang rust pyre/pyre-interpreter/src/builtins.rs

Repository: youknowone/pyre

Length of output: 153


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files pyre/pyre-interpreter/src | rg '(^|/)(builtins|call|residual|builtin|gc_roots|pyre_object)' || true

echo "== target function context =="
sed -n '11400,11455p' pyre/pyre-interpreter/src/builtins.rs 2>/dev/null || true

echo "== dir_names_from_locals_mapping occurrences =="
rg -n -C 8 'dir_names_from_locals_mapping|jit_dir_names_from_locals|builtin_sorted|call_and_check|drain_backend_jit_exc' pyre/pyre-interpreter/src || true

echo "== call.rs exception-related symbols =="
fd -a 'call.*\.rs$' pyre/pyre-interpreter/src | while read -r f; do
  echo "--- $f"
  rg -n -C 3 'set_call_error|take_call_error|BH_LAST_EXC_VALUE|store_exception|last_exc|BackendJit|drain|PendingException|exception|Error' "$f" || true
done

Repository: youknowone/pyre

Length of output: 50374


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== call_function definitions/callers =="
rg -n -C 8 '^pub (fn|unsafe fn) call_function|fn call_function|pub fn call_and_check|fn call_function_impl_result|drain_backend_jit_exc|take_call_error|set_call_error|BH_LAST_EXC|last_call|PendingException|stored call' pyre/pyre-interpreter/src/call.rs pyre/pyre-interpreter/src/builtins.rs pyre/pyre-interpreter/src/baseobjspace.rs pyre/pyre-interpreter/src/typedef.rs | head -n 240

echo "== builtin_sorted implementation =="
sed -n '12760,12855p' pyre/pyre-interpreter/src/builtins.rs

echo "== residual_call references =="
find pyre/pyre-interpreter/src -name '*residual*' -type f -print
rg -n -C 5 'drain_backend_jit_exc|PendingException|last.*exc|call_error' pyre/pyre-interpreter/src | head -n 220

Repository: youknowone/pyre

Length of output: 42315


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== pending call error helpers =="
rg -n -C 8 'PENDING_CALL_ERROR|take_call_error|clear_call_error|capture_pending_call_error_area|walk_pending_call_error_area|drain.*call.*exc|CallMode::Bare|call_modelled' pyre/pyre-interpreter/src/call.rs pyre/pyre-interpreter/src/pyframe.rs pyre/pyre-interpreter/src/pyobject.rs | sed -n '1,260p'

echo "== dir_names_from_locals_mapping calls and error handling =="
rg -n -C 5 'jit_dir_names_from_locals|dir_names_from_locals_mapping|builtin_dir\(' pyre/pyre-interpreter/src/builtins.rs pyre/pyre-interpreter/src/

echo "== all call_function result recovery around dir/sorted helpers =="
sed -n '8827,8845p' pyre/pyre-interpreter/src/builtins.rs

Repository: youknowone/pyre

Length of output: 21196


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== builtin_sorted call path =="
rg -n -C 6 'fn call_builtin_code_positional|pub fn call_function\(|pub fn call_user_function|clear_call_error\(|take_call_error\(|call_function_impl_raw|CallMode' pyre/pyre-interpreter/src/call.rs | sed -n '1,380p'

echo "== inspect call_function_result/call_function_raw definitions =="
sed -n '3161,3310p' pyre/pyre-interpreter/src/call.rs

echo "== inspect call_function in baseobjspace result returns =="
rg -n 'call_function\(|pub fn call_function\(callable: PyObjectRef|pub fn call_method\(' pyre/pyre-interpreter/src/baseobjspace.rs | head -n 20
fd -a 'baseobjspace.rs' pyre/pyre-interpreter/src -x sh -c 'echo "--- $1"; rg -n -C 4 "pub fn call_function\(|pub fn call_method\(" "$1"' sh {}

echo "== jit_dir_names callers =="
rg -n -C 4 'jit_dir_names_from_locals|dir_names_from_locals_mapping' pyre/pyre-interpreter/src/builtins.rs pyre/pyre-interpreter/src

Repository: youknowone/pyre

Length of output: 34138


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== builtin_sorted exact implementation slice =="
sed -n '12768,12855p' pyre/pyre-interpreter/src/builtins.rs

echo "== sort_rooted_items implementation =="
rg -n -C 8 'fn sort_rooted_items|fn finish_builtin_code_positional|pub fn call_method\(' pyre/pyre-interpreter/src/builtins.rs pyre/pyre-interpreter/src/baseobjspace.rs | sed -n '1,220p'

Repository: youknowone/pyre

Length of output: 7632


Drain the stashed call error on the PY_NULL path.

dir_names_from_locals_mapping uses Result-returning paths, and builtin_sorted also returns Result<..., PyError> after collect_iterable. A user override that returns PY_NULL would stash the error in PENDING_CALL_ERROR; returning PY_NULL here would leave that side effect for the residual dir(). Clear the pending call error in the Err(_) path.

🤖 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-interpreter/src/builtins.rs` around lines 11433 - 11442, Update
jit_dir_names_from_locals so its Err(_) branch drains the pending call error
from PENDING_CALL_ERROR before returning PY_NULL, matching the error-handling
behavior required for Result failures from dir_names_from_locals_mapping.

Comment thread pyre/pyre-interpreter/src/module/signal/interp_signal.rs
Comment on lines +1705 to +1712
// `w_list_clear` is the twin destructive re-installation and holds the
// stripe lock across its `drop_object_items`; a concurrent reader must not
// see the half-installed state this one writes either. Take the lock after
// the allocating `build_list_storage` so the acquire cannot deadlock behind
// a collection, and reload `obj` behind it because a contended acquire
// blocks through `before_external_block`.
let _list_guard = w_list_lock(obj);
let obj = crate::gc_roots::shadow_stack_get(obj_slot);

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 | 🔴 Critical | 🏗️ Heavy lift

Use a stable lock identity for the list.

At Line 1711, w_list_lock selects its stripe from the pre-block object address. On contention, before_external_block can relocate obj, as Line 1712 acknowledges. A later caller can select a different stripe from the relocated address while this caller reloads and mutates the same list under the old stripe lock. This can concurrently free or replace list storage.

Store or derive the lock identity from non-moving list-owned state. Do not derive it from a relocatable address.

🤖 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/listobject.rs` around lines 1705 - 1712, Update the lock
acquisition in the list mutation path around w_list_lock so its stripe identity
comes from stable list-owned state rather than the pre-block relocatable obj
address. Ensure callers use the same identity after before_external_block
relocation, while preserving the post-lock shadow_stack_get reload and existing
lock coverage.

@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: 85fa2df06b

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

// the allocating `build_list_storage` so the acquire cannot deadlock behind
// a collection, and reload `obj` behind it because a contended acquire
// blocks through `before_external_block`.
let _list_guard = w_list_lock(obj);

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 Root the new items block before acquiring the list lock

When w_list_init_items builds an Object-strategy replacement and this stripe-lock acquisition contends, w_list_lock enters the before_external_block GC safepoint while storage.block is only an unrooted native pointer. The builder's own contract says this fresh nursery block must be pinned across any subsequent safepoint, and reload_typed_blocks only repairs the int/float blocks, so a collection can move or reclaim the object block before its stale address is installed into the list. Pin and reload storage.block across the acquire as well.

Useful? React with 👍 / 👎.

Comment on lines +6566 to +6569
let dict_root = pyre_object::gc_roots::shadow_stack_len();
pyre_object::gc_roots::pin_root(
pyre_interpreter::pyframe::jit_locals_dict_new() as pyre_object::PyObjectRef
);

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 frame-local extras in the locals fold

When an optimized frame already has a non-fast-local entry, such as after sys._getframe().f_locals["extra"] = value, the interpreter's fast2locals updates the cached w_locals mapping without deleting that extra key and frame_locals_snapshot copies the whole mapping. This fold instead starts from an empty dict and adds only code.varnames, so compiled locals()/vars() silently omit the entry and dir() omits its name. The fold must incorporate the frame-owned mapping or decline when it contains entries outside the modeled slots.

AGENTS.md reference: AGENTS.md:L14-L18

Useful? React with 👍 / 👎.

/// object's `numlocals`; the explicit ceiling here keeps a pathologically
/// wide frame from turning one `locals()` into hundreds of trace ops. Over
/// the bound the fold declines and the generic residual runs (SAFE).
const MAX_MODELLED_FASTLOCALS: usize = 32;

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 non-upstream 32-local tracing cutoff

For any optimized function with at least 33 entries in co_varnames, every zero-argument locals()/vars()/dir() call is forced onto the generic residual path; that path forces the virtualizable and aborts the trace, so such a hot loop cannot compile even though upstream's @jit.unroll_safe loop has no equivalent numeric cutoff. Translate the upstream loop shape or segment the generated trace rather than rejecting these frames outright.

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

Useful? React with 👍 / 👎.

Comment on lines +4889 to +4894
if unsafe { crate::objspace::std::mapdict::has_mapdict_storage(obj) } {
return Ok(unsafe {
crate::objspace::std::mapdict::instance_node_getdictvalue(
obj,
rustpython_wtf8::Wtf8::new(name),
)

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 Propagate errors from devolved mapdict lookups

For a devolved native mapdict carrier such as a _random.Random subclass whose object-strategy __dict__ contains a same-hash non-string key with a raising __eq__, this new branch wraps instance_node_getdictvalue in Ok; its devolved terminator uses unchecked w_dict_getitem_wtf8, which turns that comparison error into a miss. The previous getdict_backing plus checked finditem_str path propagated the exception, so attribute lookup can now continue to a class value or AttributeError instead. Preserve the mapdict shape without bypassing the fallible finditem_str semantics.

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

Useful? React with 👍 / 👎.

`sys.stdout.write` and `print` do not go through the `TextIOWrapper` /
`BufferedWriter` pair `make_std_stream` builds; the stdout `write` is shadowed by
a native builtin (module/sys/vm.rs:2610) so that wasm32, which has no fd 1, and
the sandbox seam can capture the bytes, and so that `print` and
`sys.stdout.write` stay in order. That path reached `std::io::stdout()`, which is
a `LineWriter`, so the buffering flags never applied to it: a written value with
no newline waited in Rust's buffer until process exit.

Measured with stdout on a pipe and SIGKILL, which cannot be caught, so anything
observed had already reached the descriptor:

    -u  sys.stdout.write("AB")   3.14 "AB"   pyre "" -> now "AB"
    -u  print("AB", end="")      3.14 "AB"   pyre "" -> now "AB"

`flush_stdout_when_unbuffered` carries the rule, called from both `emit_stdout`
spellings, from `print_output`, and from the vm write builtin. Under sandbox fd 1
is already written through unbuffered `ll_os_write` requests, so it is a no-op
there.

An unflagged run is unchanged, and still line-buffers a pipe where CPython
block-buffers it.

Assisted-by: Claude
…_str re-entry

`base_exception_str_wtf8`'s one-argument branch calls `py_str_wtf8` on the
element in tail position. Release builds turn that into a jump, so a
self-referential `e.args = (e,)` cycled with a constant stack pointer and a
constant frame count, and neither half of `stack_check` could observe it.

Take a `call::enter_native_dispatch` unit at the function's entry, as the
equally frameless `A.__call__ = A()` chain does, and add the `stack_check`
`py_repr_wtf8` already carries to `py_str_wtf8`.

`str(e)` now raises RecursionError where it previously ran until killed.

Assisted-by: Claude
`app_math.py:factorial` tests `'__index__' not in dir(n)`, which reports
membership only. The port used `lookup_special`, which walks the MRO and then
calls the descriptor's `__get__`, so a non-function `__index__` descriptor was
bound twice per call: once for the discarded check and once inside
`get_bigint`.

Use `lookup`, the non-binding MRO walk, leaving `get_bigint` as the single
binding site. A descriptor whose `__get__` returns a different callable per
bind now yields the first one: `math.factorial` on such an object returned 2
where it should return 1.

Assisted-by: Claude
…uncating

`confname_arg` and the `sysconf` integer arm cast the index to `i32`, so
`os.pathconf(fd, 2**40)` reached the syscall as name 0 and came back EINVAL,
reporting a failure for a name the caller never passed. Convert with
`i32::try_from` and raise OverflowError on a value that does not fit, as
`conv_confname` does.

Assisted-by: Claude
`locale_implies_utf8_mode` decided from the LC_ALL/LC_CTYPE/LANG strings
themselves, so a variable naming a locale the C library cannot install
(`LC_ALL=xx_YY.invalid`) was read as a named locale and left utf8_mode 0 while
setlocale had in fact kept C.

Set LC_CTYPE from the environment, read back what was installed, and test that
instead. Restricted to the process-environment case: an embedding that supplied
its own table via `set_launch_env` is describing an environment this process
does not have, and wasm32 has no locale database, so both keep the string
cascade.

Assisted-by: Claude
…placement

`w_list_clear` is the twin destructive re-installation and takes `w_list_lock`
across its `drop_object_items`; `w_list_init_items` dropped and rewrote
`length`, `items`, `strategy`, `int_items` and `float_items` with no lock, so a
concurrent reader could observe the half-installed state. `list.sort`'s restore
path reaches both in sequence holding no outer lock.

Take the lock after the allocating `build_list_storage`, and reload `obj` behind
the acquire, which blocks through `before_external_block` when contended — the
pin/reload/lock/reload order every other mutator in the file uses.

Assisted-by: Claude
…bool__

`list.sort` read the receiver and the `key=` callable out of `args`, then
decoded `reverse=` by calling the object's `__bool__`, and only pinned
afterwards. That call runs Python and can collect, and `args` is a native copy
`call_builtin_code_positional` takes before dispatch which the collector does
not update, so re-reading it afterwards yields the same stale pointer.

Pin both before the call and reload the key from its shadow slot after, the
order `tuple_method_index` uses around `eq_w`.

Adds a gc_stress case that sorts with a `__bool__` calling `gc.collect()`.

Assisted-by: Claude
…e flush's mode through c_int_w

`signum_arg` narrowed with `as i32` before `check_signum_in_range` ran, so
`(1 << 32) | SIGINT` truncated to `SIGINT` and passed the check.  Keep the
unwrapped value machine-word wide, as the RPython `int` in
`interp_signal.py`'s `@unwrap_spec(signum=int)` is, and narrow after the
check bounds it to `1..NSIG`.  The `sigwait`/`pthread_sigmask` set walk had
the same order and gets the shared `check_signum_in_range` call.

`Compress.flush` unwrapped its mode with `int_w(o)? as i32`, truncating an
out-of-range value into a different flush mode.  `interp_zlib.py:196` spells
that argument `@unwrap_spec(mode="c_int")`, so it goes through `c_int_w`.

Measured against pypy3: `signal.getsignal((1 << 32) | 2)` and
`signal.signal` now raise `ValueError: signal number out of range`, and
`Compress.flush((1 << 32) | Z_NO_FLUSH)` raises `OverflowError: expected a
32-bit integer`, each matching upstream's message.

`c_int_w`'s doc cited baseobjspace.py:1976-1982; the function is at
:2062-2068.  It also named `sys.setrecursionlimit` the only caller.

Assisted-by: Claude
`vable_after_residual_call` raises
`SwitchToBlackhole(Counters.ABORT_ESCAPE, raising_exception=True)`
(pyjitpl.py:3389-3390).  The walker's `VableEscapedDuringResidualCall`
decline staged no reason at all, so the ladder in `jitdriver` fell through
to `AbortReason::Generic`, whose `as_int()` is `ABORT_BRIDGE`.  The escape
was therefore tallied in the slot `jitprof.rs ABORT_COUNTER_KINDS` labels
`bridge_or_generic` -- a counter named after bridges, for a decline with no
bridge in it.

`note_vable_escape_abort` stages `counters::ABORT_ESCAPE` at the single
`Err(DispatchError::VableEscapedDuringResidualCall)` site, next to the
existing `note_force_quasi_immut_abort`, which was the only
`stage_abort_reason` caller in the tree.

`raising_exception` needs no counterpart here: pyre derives it from the
residual's own `exec_result` rather than from the staged reason, and the
escape's `Err` arm already sets it.

Measured on recursive_forced_frame_kept_stack (with the pending `locals()`
frame-force change in the tree, which is what produces an escape there):
`abrt_bridge=1 abrt_escape=0` becomes `abrt_bridge=0 abrt_escape=1`.
`loops_compiled=1 bridges_compiled=3 loops_aborted=1 guard_failures=600`
are unchanged -- this reclassifies a tally, it does not recover a trace.
No `.jitstats` field records an `abrt_*` counter, so no baseline moves.

Assisted-by: Claude
…zero-argument form in the walker

`topframe_for_locals` resolved the frame with `gettopframe_nohidden()` alone,
which forces the vref but never calls `force_frame`.  `fast2locals` then read a
virtualizable whose `locals_cells_stack_w` was last flushed at the previous
deopt, so inside a compiled loop `locals()` and `vars()` returned the right keys
with values thousands of iterations stale: a loop over 200000 iterations binding
`a = i * 2` reported `a = 2082, i = 1041` at the point the frame held
`10000 / 5000`.  `sys._getframe().f_locals` was already exact in the same loop
because `getframe` calls the forcing `gettopframe()` first.  It now calls
`force_frame_before_locals_read`.

That force alone costs the loop.  `locals()` is an opaque residual, so the
walker arms the vable token protocol around it (`tracing_before_residual_call`);
the force clears the token inside that window, which is the escape signal
`tracing_after_residual_call` reads, and the walk declines.  Measured on
`for i in range(200000): d = locals()`: `loops_compiled=0 loops_aborted=5`.
Upstream has no exemption for this — `pyjitpl.py:3373-3390` raises
`SwitchToBlackhole(Counters.ABORT_ESCAPE)` unconditionally once a force is
detected.  What upstream has instead is `pyframe.py:539 fast2locals` decorated
`@jit.unroll_safe`, which `codewriter/policy.py:60-61,67` keeps looked-into, so
the fastlocals reads lower to `getarrayitem_vable_r` and no residual boundary
exists to arm.

`try_walker_specialize_builtin_locals` models that shape here.  It sits in the
walker specialization chain in `residual_call.rs`, which runs before
`try_execute_residual_call_via_executor` arms the protocol, and expands a
zero-argument `locals()` / `vars()` / `dir()` on the walk's own portal frame into
per-slot reads plus a dict-build chain.  `jit_locals_dict_new` and
`jit_locals_dict_setitem_local` take a code object and a slot index rather than a
`PyFrame`, so `force_frame` is unreachable from them.  The fold declines — and
falls through to the generic residual — on wrong arity, a callable that is not
the builtin, a null receiver, an inline subwalk, a frame whose vable pointer is
not the portal's, a code object that is not plain fastlocals
(`code_locals_are_plain_fastlocals`: OPTIMIZED, no cellvars, no freevars, no
hidden locals), more slots than `MAX_MODELLED_FASTLOCALS`, and an unexpected
vable info shape.

`vars()` folds with `locals()` because zero-argument `vars()` is `locals()`
(`app_inspect.py:21-24`).  `dir()` shares `topframe_for_locals`, so it shared the
regression and shares the fold; `dir(obj)` carries an argument and never reaches
it.

Measured after, on `recursive_forced_frame_kept_stack`: loops 2, bridges 5,
aborted 0, guard_failures 1000, `fbw_rolled_back_with_effects` 0 — unchanged from
the committed baselines on all three backends.  Hot `locals()` goes from
`loops_compiled=0 loops_aborted=5` to `1 / 0`, and hot no-argument `dir()` the
same.  Sixteen decline paths (closure free-var visible, rebound name,
comprehension, class body, the PEP 667 snapshot, hot `vars()`,
`sys._getframe(0).f_locals`, args/kwargs, and the eight `dir()` twins) match
python3.14 exactly.

`locals_forced_frame` covers the stale-value shape behind a cold guard.  The doc
comments on `topframe_for_locals`, `gettopframe_nohidden` and the typedef
frame-walk note claimed an unforced frame yields an empty mapping; it yields
correct keys with stale values, which is why key-only probes missed this.

Assisted-by: Claude
…outing through getdict

`baseobjspace::getdictvalue`/`setdictvalue` carried only the `W_Root`
defaults (baseobjspace.py:46-50, 52-57) and applied them to every
receiver, resolving the instance dict through `getdict_backing` ->
`getdict` -> `_obj_getdict`.  For a mapdict carrier that materialises the
`("dict", SPECIAL)` wrapper, which adds an attribute to the instance's
map: an ordinary `self.a = a` moved the receiver from `Plain("a")` to
`Plain("dict", SPECIAL, back=Plain("a"))`.

mapdict.py:846-850 `MapdictDictSupport` overrides both for a mapdict
carrier and reads/writes the map directly.  Port those overrides, gated
on `has_mapdict_storage`.

`instance_node_setdictvalue` now returns `map.write`'s flag rather than
debug_asserting it: a `NoDictTerminator` (`__slots__`) answers false, and
that is the AttributeError signal `object_setattr` raises on.  The
`assert flag` moves to `MapDictStrategy::setitem_str`, where
mapdict.py:1172-1175 has it.

The JIT bakes the pre-materialisation map into the `GuardValue` that
`walker_guard_mapdict_instance_shape` emits, so the shape change made
that guard fail on every entry until a bridge attached.  Re-recorded
baselines, guard_failures and bridges_compiled, dynasm/cranelift/wasm:

  mapdict_frozen_unboxing_fold     203/203/404 -> 2/2/2,  bridges 1/1/2 -> 0
  sre_pattern_methods              2291/2291/2292 -> 1012/1012/1013, bridges 11 -> 5
  sre_wasm_min                     1849 -> 803, bridges 8 -> 4
  sre_wasm_min1                     803 -> 603, bridges 4 -> 3
  polymorphic_binary_receiver      1045 -> 788, bridges 4 -> 3
  pickle_terminal_raise_resume     463/656/464 -> 338/338/339, loops 36/36/73 -> 35/35/72
  pickle_ctor_args (cranelift)      201 -> 1, bridges 1 -> 0
  inline_callee_constructs_object   201 -> 1, bridges 1 -> 0
  foriter_inplace_immutable         202 -> 1, bridges 1 -> 0, loops 1 -> 2

The three cranelift-only rows are a selection effect, not a
backend-specific cause: `PyFrame::store_attr_cached` (eval.rs:4630) ports
pyopcode.py:920 `if not jit.we_are_jitted():` and skips the mapdict cache
when jitted, falling through to `object_setattr` -> `setdictvalue`, and
`majit-backend-cranelift/src/compiler.rs:7424` is the only setter of that
flag in the tree.  Filed as a separate task.

`__dict__` observable behaviour checked against python3.14 on a 13-case
fixture (materialisation, `vars()`, aliasing, `__dict__` assignment,
`del`, `__slots__` rejection, 120-attribute devolve, surrogate-named
attribute): byte-identical.

Assisted-by: Claude
…alled

`note_vable_escape_abort` reached the driver through
`driver::driver_pair`, which resolves it via `callbacks::get` and panics
with "CallJitCallbacks not initialized" when no table is installed.  A
skeleton walk drives `jitcode_dispatch` directly with no pyre-jit eval
behind it, so `jitcode_dispatch::tests::may_force_vable_escape_surfaces_
typed_abort` — which exercises exactly the escape arm the note was added
to — panicked instead of asserting its typed error.  All three
`cargo test` legs failed on it, each 341 passed / 1 failed.

Add `driver::try_driver_pair`, the `callbacks::try_get` spelling of the
same lookup, and route both abort-reason notes through one
`stage_walker_abort_reason` that returns early without a driver.  The
adjacent `drain_backend_jit_exc` call in the same arm
(residual_call.rs:3223-3227) already takes `try_get` for this reason.

Staging a reason is accounting only: the abort travels in the
`DispatchError` the caller returns and no consumer reads the slot back,
so skipping it changes nothing a skeleton walk observes.  Production is
unaffected — the walker reaches the panicking `driver_pair` at ten other
sites, so a callback-less thread cannot get this far.
`note_force_quasi_immut_abort` had the same latent panic and is covered
by the shared helper.

cargo test --all --features dynasm: 7489 passed, 0 failed (101
harnesses).  The cranelift subset CI runs after it: 3054 passed, 0
failed (46 harnesses).

Assisted-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

https://github.com/youknowone/pyre/blob/e855a9ec4d4be34aad7c9abbb3483f7df1731cc5/pyre-jit-trace/src/jitcode_dispatch/specialize.rs#L6465-L6466
P1 Badge Model locals for inlined callee frames

When an inlined helper calls zero-argument locals(), vars(), or dir(), this gate always declines the fold; the generic residual then reaches force_frame_before_locals_read, and force_pyframe treats the published inline frame as escaping the traced virtualizable, clears the tracing token, and aborts the entire portal trace. Consequently, a hot loop cannot compile merely because an otherwise-inlineable helper inspects its locals, whereas upstream traces the @jit.unroll_safe fast2locals reads. Source the values from the callee shadow by preserving its per-frame red frame rather than restricting the model to the portal virtualizable.

AGENTS.md reference: AGENTS.md:L32-L42

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

@youknowone
youknowone merged commit 1995d92 into main Aug 6, 2026
7 of 8 checks passed
@youknowone
youknowone deleted the wasm-jit branch August 6, 2026 10:23

@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

https://github.com/youknowone/pyre/blob/b8ba6a9c500b7196940f0c96f14dabb807e7a37f/pyre-interpreter/src/display.rs#L1006
P2 Badge Limit the recursion check to actual string re-entry

When execution is already in the deepest permitted Python frame—for example, while handling the RecursionError from one further recursive call—an ordinary str(123) now raises another RecursionError even though it performs no recursive dispatch. execute_frame checks before incrementing the frame depth specifically so builtin/object-space operations remain usable while handling recursion failures, but this unconditional check observes py_recursion_depth == recursionlimit; apply the check only around the recursive BaseException.args re-entry that this change is intended to guard.

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

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

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