From a62f004cd2c91f3215d7fdf4e96d2d6082d644d8 Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Fri, 7 Aug 2026 15:32:36 +0900 Subject: [PATCH 1/4] jit(wasm): drop the wasm32 arm of the self-recursive root-bridge inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bridge_rec_root_selfrec` (inline_call.rs) was `cfg!(not(target_arch = "wasm32"))`, so a guard-failure bridge reaching a self-recursive callee's CALL took the root-bridge admission on the native backends and residualized on wasm. The cfg's stated reason — "the wasm always-portal path type-confuses the self-recursive inline (`setintbound: got Ref`)" — is phrasing carried over from #643 `920367da965`, where it belonged to `fbw_inline_callee_hazardous`: a decline that is not cfg-gated and rests on the CALL_ASSEMBLER trampoline frame. #749 `71726847a10` introduced the cfg and reported dynasm + cranelift 294/294 for that slice with no wasm number. `always-portal` names nothing in the tree. Neither mechanism that would make such a confusion wasm-specific holds: `W_IntObject.intval` is i64 on every target, so there is no word-size Int→Ref promotion, and general wasm CALL_ASSEMBLER support landed in #564 `c89254a6211` on 2026-07-15, before the claim. Add `synth/selfrec_bridge_nontail_promote`, covering the admission in the form `bridge_recursion_overflow` does not: non-tail recursion, so the guard's resume stream is multi-frame; a Ref local live across the recursive CALL; and an accumulator crossing 2**63 at level 13 of 24, so a long — a Ref — reaches the int operations. wasm reads loops/bridges/aborted/gf 2/4/1/606 with the decline and 2/6/0/806 without it, byte-identical to dynasm and cranelift. wasm jit-stats re-recorded for the five fixtures the admission moves. All five now carry the native values except `recursion_memo_branch`, one guard failure apart, previously twenty-one. check.py dynasm 405/406, cranelift 405/406, wasm 401/402. The one failure, `synth/pypy_type_surface`, reports the same `bridges_compiled 5 -> 102, guard_failures 1011 -> 20497` with this change's two files restored to origin/main, and it fails identically on all three backends while the change is a no-op wherever the cfg already read true. The fixture also holds no self-recursive function for `code_is_self_recursive` to answer. wasm user+sys CPU, min/median over 31 interleaved samples, decline removed vs kept: `wasm_ca_trampoline_decline` -20.1%/-20.7%, `selfrec_bridge_nontail_promote` -12.9%/-11.9%, `recursion_memo_branch` -1.2%/-1.6%. Two get slower: `ca_bridge_multiframe_resume_double_call` +1.2%/+2.7% and `foriter_call_resume_drops_iteration` +2.4%/+4.0%. Both now report what the dynasm baseline records — 16 bridges / 0 aborts and 27 bridges / 1 abort — where the decline left them at 16/1 and 26/2, so the cost is wasm taking the native decision and its compile toll. Assisted-by: Claude --- ...ultiframe_resume_double_call.wasm.jitstats | 8 +++- ..._call_resume_drops_iteration.wasm.jitstats | 9 +++-- .../synth/recursion_memo_branch.wasm.jitstats | 9 ++++- ..._bridge_nontail_promote.cranelift.jitstats | 14 +++++++ ...rec_bridge_nontail_promote.dynasm.jitstats | 14 +++++++ .../synth/selfrec_bridge_nontail_promote.py | 38 +++++++++++++++++++ ...lfrec_bridge_nontail_promote.wasm.jitstats | 14 +++++++ .../wasm_ca_trampoline_decline.wasm.jitstats | 9 ++++- .../src/jitcode_dispatch/fbw_state.rs | 9 ++--- .../src/jitcode_dispatch/inline_call.rs | 22 +++++------ 10 files changed, 119 insertions(+), 27 deletions(-) create mode 100644 pyre/bench/synth/selfrec_bridge_nontail_promote.cranelift.jitstats create mode 100644 pyre/bench/synth/selfrec_bridge_nontail_promote.dynasm.jitstats create mode 100644 pyre/bench/synth/selfrec_bridge_nontail_promote.py create mode 100644 pyre/bench/synth/selfrec_bridge_nontail_promote.wasm.jitstats diff --git a/pyre/bench/synth/ca_bridge_multiframe_resume_double_call.wasm.jitstats b/pyre/bench/synth/ca_bridge_multiframe_resume_double_call.wasm.jitstats index 9608ba1836a..ac1c9caa97a 100644 --- a/pyre/bench/synth/ca_bridge_multiframe_resume_double_call.wasm.jitstats +++ b/pyre/bench/synth/ca_bridge_multiframe_resume_double_call.wasm.jitstats @@ -2,9 +2,13 @@ bridges_compiled=16 descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 +fbw_blackhole_adopted_multi_frame=0 +fbw_blackhole_adopted_single_frame=0 fbw_rolled_back_with_effects=0 fbw_store_journal_rollback_failed=0 -guard_failures=2581 +field_pos_attached_misplaced=0 +field_pos_spec_misplaced=0 +guard_failures=2592 internal_compile_panics=0 -loops_aborted=1 +loops_aborted=0 loops_compiled=3 diff --git a/pyre/bench/synth/foriter_call_resume_drops_iteration.wasm.jitstats b/pyre/bench/synth/foriter_call_resume_drops_iteration.wasm.jitstats index d9bd2ab6044..802fbdb5388 100644 --- a/pyre/bench/synth/foriter_call_resume_drops_iteration.wasm.jitstats +++ b/pyre/bench/synth/foriter_call_resume_drops_iteration.wasm.jitstats @@ -1,11 +1,14 @@ -bridges_compiled=26 +bridges_compiled=27 descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 +fbw_blackhole_adopted_multi_frame=0 +fbw_blackhole_adopted_single_frame=0 fbw_rolled_back_with_effects=0 +fbw_store_journal_rollback_failed=0 field_pos_attached_misplaced=0 field_pos_spec_misplaced=0 -guard_failures=5165 +guard_failures=5150 internal_compile_panics=0 -loops_aborted=2 +loops_aborted=1 loops_compiled=3 diff --git a/pyre/bench/synth/recursion_memo_branch.wasm.jitstats b/pyre/bench/synth/recursion_memo_branch.wasm.jitstats index 2fc0df5d183..0105f69912f 100644 --- a/pyre/bench/synth/recursion_memo_branch.wasm.jitstats +++ b/pyre/bench/synth/recursion_memo_branch.wasm.jitstats @@ -2,8 +2,13 @@ bridges_compiled=28 descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 +fbw_blackhole_adopted_multi_frame=0 +fbw_blackhole_adopted_single_frame=0 fbw_rolled_back_with_effects=0 -guard_failures=4724 +fbw_store_journal_rollback_failed=0 +field_pos_attached_misplaced=0 +field_pos_spec_misplaced=0 +guard_failures=4704 internal_compile_panics=0 -loops_aborted=2 +loops_aborted=1 loops_compiled=3 diff --git a/pyre/bench/synth/selfrec_bridge_nontail_promote.cranelift.jitstats b/pyre/bench/synth/selfrec_bridge_nontail_promote.cranelift.jitstats new file mode 100644 index 00000000000..5309ff9b2dc --- /dev/null +++ b/pyre/bench/synth/selfrec_bridge_nontail_promote.cranelift.jitstats @@ -0,0 +1,14 @@ +bridges_compiled=6 +descr_set_absent=0 +descr_set_ambiguous=0 +descr_set_stale_absent=0 +fbw_blackhole_adopted_multi_frame=0 +fbw_blackhole_adopted_single_frame=0 +fbw_rolled_back_with_effects=0 +fbw_store_journal_rollback_failed=0 +field_pos_attached_misplaced=0 +field_pos_spec_misplaced=0 +guard_failures=806 +internal_compile_panics=0 +loops_aborted=0 +loops_compiled=2 diff --git a/pyre/bench/synth/selfrec_bridge_nontail_promote.dynasm.jitstats b/pyre/bench/synth/selfrec_bridge_nontail_promote.dynasm.jitstats new file mode 100644 index 00000000000..5309ff9b2dc --- /dev/null +++ b/pyre/bench/synth/selfrec_bridge_nontail_promote.dynasm.jitstats @@ -0,0 +1,14 @@ +bridges_compiled=6 +descr_set_absent=0 +descr_set_ambiguous=0 +descr_set_stale_absent=0 +fbw_blackhole_adopted_multi_frame=0 +fbw_blackhole_adopted_single_frame=0 +fbw_rolled_back_with_effects=0 +fbw_store_journal_rollback_failed=0 +field_pos_attached_misplaced=0 +field_pos_spec_misplaced=0 +guard_failures=806 +internal_compile_panics=0 +loops_aborted=0 +loops_compiled=2 diff --git a/pyre/bench/synth/selfrec_bridge_nontail_promote.py b/pyre/bench/synth/selfrec_bridge_nontail_promote.py new file mode 100644 index 00000000000..d6b30f3ee7b --- /dev/null +++ b/pyre/bench/synth/selfrec_bridge_nontail_promote.py @@ -0,0 +1,38 @@ +# pyre-check: max-pypy-ratio=20 +# Coverage for the self-recursive root-bridge inline when the recursion is +# non-tail and carries a Ref local. +# +# `walk` is self-recursive, takes exact-integer arguments and holds a +# `BinaryOp` residual, so a guard-failure bridge that reaches its CALL takes the +# root-bridge admission (`bridge_rec_root_selfrec`, inline_call.rs). +# `bridge_recursion_overflow` already covers that admission, but only in its +# easiest form: tail recursion whose live set is two machine integers. Two +# ingredients of the "a Ref reached an int operation" failure it is meant to +# guard against were therefore unexercised. +# +# `acc * 2` crosses the machine-int boundary partway down the recursion, so the +# accumulator promotes to a long — a Ref — at a level that moves with the +# caller's seed, and the overflow guard fires inside the recursive frame. `tag` +# keeps a second Ref live across the recursive CALL beside it, and the non-tail +# `inner + len(tag)` leaves a paused caller chain, so the guard's resume stream +# is multi-frame and mixes Ref with Int rather than being one frame of +# integers. +# +# Byte-parity against CPython/PyPy is the gate: Python integers are unbounded, +# so the promotion must not be observable in the result. +_TAGS = ("a", "bb", "ccc", "dddd") + + +def walk(n, acc): + if n == 0: + return acc + tag = _TAGS[n & 3] + nxt = acc * 2 + len(tag) + inner = walk(n - 1, nxt) + return inner + len(tag) + + +out = [] +for i in range(50000): + out.append(walk(24, (1 << 50) + i) % 1000000007) +print(out[0], out[-1], sum(out) % 1000000007) diff --git a/pyre/bench/synth/selfrec_bridge_nontail_promote.wasm.jitstats b/pyre/bench/synth/selfrec_bridge_nontail_promote.wasm.jitstats new file mode 100644 index 00000000000..5309ff9b2dc --- /dev/null +++ b/pyre/bench/synth/selfrec_bridge_nontail_promote.wasm.jitstats @@ -0,0 +1,14 @@ +bridges_compiled=6 +descr_set_absent=0 +descr_set_ambiguous=0 +descr_set_stale_absent=0 +fbw_blackhole_adopted_multi_frame=0 +fbw_blackhole_adopted_single_frame=0 +fbw_rolled_back_with_effects=0 +fbw_store_journal_rollback_failed=0 +field_pos_attached_misplaced=0 +field_pos_spec_misplaced=0 +guard_failures=806 +internal_compile_panics=0 +loops_aborted=0 +loops_compiled=2 diff --git a/pyre/bench/synth/wasm_ca_trampoline_decline.wasm.jitstats b/pyre/bench/synth/wasm_ca_trampoline_decline.wasm.jitstats index bf08a9bf682..db386c7fc38 100644 --- a/pyre/bench/synth/wasm_ca_trampoline_decline.wasm.jitstats +++ b/pyre/bench/synth/wasm_ca_trampoline_decline.wasm.jitstats @@ -1,9 +1,14 @@ -bridges_compiled=1 +bridges_compiled=3 descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 +fbw_blackhole_adopted_multi_frame=0 +fbw_blackhole_adopted_single_frame=0 fbw_rolled_back_with_effects=0 -guard_failures=404 +fbw_store_journal_rollback_failed=0 +field_pos_attached_misplaced=0 +field_pos_spec_misplaced=0 +guard_failures=601 internal_compile_panics=0 loops_aborted=1 loops_compiled=2 diff --git a/pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs b/pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs index 9b0e4cb198e..e29814867ef 100644 --- a/pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs +++ b/pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs @@ -1357,10 +1357,9 @@ fn fbw_deny_hazardous_inline(callee_code_key: usize) { /// the iterator (the two `foriter_exempt_*` witnesses). /// * **Self-recursive** — the callee calls itself. A hot self-recursion /// forms a `CALL_ASSEMBLER` bridge whose moving-nursery callee frame cannot -/// survive the residual trampoline retaining a pre-call frame pointer; on -/// the wasm always-portal path the inlined body also type-confuses the -/// optimizer (`setintbound: got Ref`, the `wasm_ca_trampoline_decline` -/// witness). Detected both dynamically (the same `w_code` already nested in +/// survive the residual trampoline retaining a pre-call frame pointer (the +/// `wasm_ca_trampoline_decline` witness). Detected both dynamically (the +/// same `w_code` already nested in /// the framestack — mutual/deep recursion) and statically /// (`code_is_self_recursive`), since the recursive call residualizes to a /// `CALL_ASSEMBLER` rather than nesting the framestack, so it is already a @@ -1426,7 +1425,7 @@ pub(crate) fn fbw_abort_nested_unjournaled_residual( // [`fbw_inline_callee_hazardous`]: a LOOP-BEARING callee (the FOR_ITER // Option-C refused-delivery double-advance, the `foriter_exempt_*` // witnesses) and a SELF-RECURSIVE callee (the hot `CALL_ASSEMBLER` - // recursion-bridge / wasm always-portal `setintbound` type-confusion, the + // recursion-bridge frame the residual trampoline cannot retain, the // `wasm_ca_trampoline_decline` witness). Both are properties of the // framestack knowable at the residual decline point, so the whole trace // aborts before the hazardous body is committed. Every other nested diff --git a/pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs b/pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs index 5518ae5b279..b0a20cb398e 100644 --- a/pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs +++ b/pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs @@ -2865,13 +2865,10 @@ pub(crate) fn try_walker_inline_resolved_user_call( // body sub-walk reaches its own recursive CALL as a nested residual, which // `fbw_abort_nested_unjournaled_residual` declines on the self-recursive // hazard arm — an abort storm that folds the whole guard bridge back to - // residual. The native `CALL_ASSEMBLER` self-recursion fold already exempts - // that decline via `SELFREC_CA_FOLD_ACTIVE`; the same exemption applies to - // this admitted inline, whose recursive residual runs concretely at the - // pre-execute site (executed, so no replay double-apply). Native only: the - // wasm always-portal path type-confuses the self-recursive inline - // (`setintbound: got Ref`), so it keeps the correct residual-fallback - // decline. + // residual. The `CALL_ASSEMBLER` self-recursion fold already exempts that + // decline via `SELFREC_CA_FOLD_ACTIVE`; the same exemption applies to this + // admitted inline, whose recursive residual runs concretely at the + // pre-execute site (executed, so no replay double-apply). let mut bridge_rec_root_selfrec = false; if ctx.trace_ctx.is_bridge_trace && args_all_builtin_integer @@ -2911,12 +2908,11 @@ pub(crate) fn try_walker_inline_resolved_user_call( if !safe_root_bridge { return Ok(None); } - bridge_rec_root_selfrec = cfg!(not(target_arch = "wasm32")) - && unsafe { - let raw = pyre_interpreter::w_code_get_ptr(w_code as pyre_object::PyObjectRef) - as *const pyre_interpreter::CodeObject; - !raw.is_null() && pyre_interpreter::code_is_self_recursive(&*raw) - }; + bridge_rec_root_selfrec = unsafe { + let raw = pyre_interpreter::w_code_get_ptr(w_code as pyre_object::PyObjectRef) + as *const pyre_interpreter::CodeObject; + !raw.is_null() && pyre_interpreter::code_is_self_recursive(&*raw) + }; } // A callee `fbw_abort_nested_unjournaled_residual` already named on its // hazard arm residualizes from here on. The hazard is a static property of From 95ec7f2d474ca8e507186df533b6dfb1ce32d44f Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Fri, 7 Aug 2026 23:27:31 +0900 Subject: [PATCH 2/4] jit: decline the `Cls.__name__` fold when its metaclass guard cannot hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `try_walker_specialize_load_type_name_attr` takes the metaclass from `baseobjspace::type_name_obj_fast_path`, which asks `typedef::type`. That falls back to `gettypefor(ob_type)` when the receiver's `w_class` slot is null (typedef.rs:234-243; the objects it covers sit in RODATA, so writing the slot would SIGBUS). The fold then guards the metaclass by reading the raw `w_class` field, so for such a receiver it emits `guard_value(NULL, type)` — a guard that fails on every execution and that nothing can discharge, because no later write fills the slot. One class reaches it. The `getset_descriptor` type object is built lazily from inside the init loop (typedef.rs:9501) as a builder for the descriptors other typedefs install, so it never enters the registry the post-loop sweep walks to stamp `w_class = type` (typedef.rs:1546-1553). `typedef::type` still answers `type` through the fallback, so `type(x)` and `x.__class__` are correct and the null slot is invisible from Python. `synth/pypy_type_surface` reads `type(type.__dict__[name]).__name__`, so it paid one guard failure per iteration on every backend: dynasm, cranelift and wasm all reported `bridges_compiled 5 -> 102, guard_failures 1011 -> 20497` against the figures #999 `dad2a722907` recorded — 19486 excess failures over 20000 iterations, and 19486/200 = 97 excess bridges, one per `trace_eagerness` bucket. #1097 `d51ea7f32a0`, which added the fold, merged 83 seconds before #999, and the recorded figures are the ones this decline reproduces. Localized by taking the fixture apart: `check_descriptor_kinds` alone carries all 97 bridges; the receiver's own value is irrelevant (a loop-invariant object reads the same), the `FOR_ITER` is irrelevant (removing it reads the same), and `int`, `str`, `list`, `NoneType`, `object`, `type`, `method_descriptor`, `wrapper_descriptor`, `builtin_function_or_method` and a user class are all clean. `getattr(C, "__name__")` — same semantics, but the fold declines because the site's name is not in the code's name table — is clean too. check.py dynasm 406/406, cranelift 406/406, wasm 402/402, all green. `synth/pypy_type_surface` reads its recorded 11/5/0/1011 exactly and `synth/type_name_attr_fold` still reads its own 5/0/1/4, so the fold keeps applying wherever its guard holds. The null slot itself is left alone. Stamping `w_class` on the `getset_descriptor` type object would restore the invariant the sweep's comment states and let the fold apply there as well, but it moves recorded baselines and belongs in its own change. Assisted-by: Claude --- pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs b/pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs index 5221aec5ee8..c14af3fbad6 100644 --- a/pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs +++ b/pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs @@ -3288,6 +3288,16 @@ pub(crate) fn try_walker_specialize_load_type_name_attr( else { return Ok(None); }; + // The metaclass guard below reads the raw `w_class` slot, while the fast + // path answers through `typedef::type`, which falls back to + // `gettypefor(ob_type)` when that slot is null. A receiver reached through + // that fallback would be guarded against a value its field never holds, and + // nothing writes the slot afterwards, so the guard would fail on every + // execution forever — one bridge per `trace_eagerness` bucket, without ever + // converging. Fold only what the guard can discharge. + if !std::ptr::eq(unsafe { (*concrete_obj).w_class }, metatype) { + return Ok(None); + } // guard_class(obj, ob_type): the `W_TypeObject` layout both field reads // below index into. `is_type` is this check. From 203cd0c9d861096b867cb7c3047389a3c823a85a Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Sat, 8 Aug 2026 01:59:42 +0900 Subject: [PATCH 3/4] parity_tests: gate os_utime_pathconf_truncate's POSIX-only halves on Windows Two parts of the script do not run there: - the `times=` keyword check passed a time before the epoch, which the block above it already excludes Windows from, and the pyre backends answer it with `ValueError: utime: timestamp out of range`; - `pathconf` and the `pathconf_names` table it resolves through are registered under `#[cfg(unix)]`, and CPython's `os` carries neither on Windows, so both runtimes raise `AttributeError`. `pyre/check.py (windows-latest)` reports `os_utime_pathconf_truncate.py cpython=FAIL dynasm=FAIL cranelift=FAIL` at the merge-base d936eb4be42. The keyword spelling is now checked with a time the platform holds, and the pathconf section runs where the names table exists. Assisted-by: Claude --- .../os_utime_pathconf_truncate.py | 46 +++++++++++-------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/pyre/extra_tests/parity_tests/os_utime_pathconf_truncate.py b/pyre/extra_tests/parity_tests/os_utime_pathconf_truncate.py index 98c1b2cd2bd..cfac974f09d 100644 --- a/pyre/extra_tests/parity_tests/os_utime_pathconf_truncate.py +++ b/pyre/extra_tests/parity_tests/os_utime_pathconf_truncate.py @@ -70,9 +70,14 @@ def raises(call, exc): os.close(fd) # `times` is the one argument here that may be spelled either way — it sits -# before the keyword-only marker. -os.utime(p, times=(-5.0, -6.0)) -check(os.stat(p).st_mtime_ns == -6_000_000_000, "utime(times=...) by keyword") +# before the keyword-only marker. The pair is one the platform holds: Windows +# refuses times before the epoch, for the reason given above. +atime, mtime = (5.0, 6.0) if sys.platform == "win32" else (-5.0, -6.0) +os.utime(p, times=(atime, mtime)) +check( + os.stat(p).st_mtime_ns == int(mtime) * 1_000_000_000, + f"utime(times=...) by keyword -> {os.stat(p).st_mtime_ns}", +) raises(lambda: os.utime(p, (1, 2), times=(3, 4)), TypeError) # Back to a time the rest of the file can be reasoned about. @@ -82,6 +87,10 @@ def raises(call, exc): # Every name the table carries either answers with a number or refuses the # question — the terminal-only limits are not ones a regular file has. What no # answer may be is None: a host with no determinate value says so with -1. +# +# `pathconf` and the `pathconf_names` table it resolves through are a POSIX +# surface; neither runtime carries them on Windows, so there is nothing to +# compare there. def limits(target): for name in sorted(os.pathconf_names): try: @@ -93,22 +102,23 @@ def limits(target): yield name, limit -answered = dict(limits(p)) -check(answered, "pathconf answered no name at all") -check("PC_NAME_MAX" in answered, "pathconf refused PC_NAME_MAX on a regular file") +if sys.platform != "win32": + answered = dict(limits(p)) + check(answered, "pathconf answered no name at all") + check("PC_NAME_MAX" in answered, "pathconf refused PC_NAME_MAX on a regular file") -if os.pathconf in os.supports_fd: - fd = os.open(p, os.O_RDONLY) - try: - by_fd = dict(limits(fd)) - check( - by_fd.get("PC_NAME_MAX") == answered["PC_NAME_MAX"], - "the descriptor and the name disagree about PC_NAME_MAX", - ) - finally: - os.close(fd) - -raises(lambda: os.pathconf(p, "PC_NOT_A_REAL_NAME"), ValueError) + if os.pathconf in os.supports_fd: + fd = os.open(p, os.O_RDONLY) + try: + by_fd = dict(limits(fd)) + check( + by_fd.get("PC_NAME_MAX") == answered["PC_NAME_MAX"], + "the descriptor and the name disagree about PC_NAME_MAX", + ) + finally: + os.close(fd) + + raises(lambda: os.pathconf(p, "PC_NOT_A_REAL_NAME"), ValueError) # ── truncate's length ───────────────────────────────────────────────────── # A length wider than off_t is not a size the file can be given; the cast that From 87c04392622436e45fbb0106c015fd78b3ebb228 Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Sat, 8 Aug 2026 01:47:38 +0900 Subject: [PATCH 4/4] jit: read an aborted inline's Ref operand list at its argcode offset `reconstructed_all_ref_call_stack` read the residual op's Ref var-list at operand offset 1. That offset holds for the Ref-only residual shape (`iRd>r`) but not for the mixed one (`iIRd>r`), where the leading Int list is variable-width and `dispatch_residual_call_iIRd_kind` reads the Ref list at `1 + i_width`. On the mixed shape the read landed on the Int list's length byte and resolved its register indices through the Ref bank. The resulting list has an unrelated length, so the operand stack the abort-flush composes from it (kept vstack prefix + this list) still matched `depth_at_py_pc` at the CALL and was committed. For `p[0]` inside a `for p in ...` body the committed stack was `[iterator, p, iterator]`, and the interpreter re-executed the subscript with the loop's iterator as the index: `re.compile("|".join("%d" % x for x in range(2000)))` raised `TypeError: list indices must be integers or slices, not list_iterator` from `_compiler.py:504 _get_charset_prefix`, failing `test.test_re` in the vendored CPython suite. Derive the offset from the op's argcodes instead, walking the widths of `blackhole.py:112-157`; an op with no Ref list declines. Covered by `jitcode_dispatch::tests::ref_var_list_offset_follows_the_argcodes_not_a_fixed_byte` and `parity_tests/foriter_body_call_abort_operand_stack.py`. Assisted-by: Claude (cherry picked from commit 624d547875d705b49e226de89ba61fca826225cd) --- .../foriter_body_call_abort_operand_stack.py | 38 +++++++++++++++ .../src/jitcode_dispatch/inline_call.rs | 9 +++- .../src/jitcode_dispatch/mod.rs | 38 +++++++++++++++ .../src/jitcode_dispatch/tests.rs | 47 +++++++++++++++++++ 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 pyre/extra_tests/parity_tests/foriter_body_call_abort_operand_stack.py diff --git a/pyre/extra_tests/parity_tests/foriter_body_call_abort_operand_stack.py b/pyre/extra_tests/parity_tests/foriter_body_call_abort_operand_stack.py new file mode 100644 index 00000000000..957b5050aa2 --- /dev/null +++ b/pyre/extra_tests/parity_tests/foriter_body_call_abort_operand_stack.py @@ -0,0 +1,38 @@ +# The operand stack an aborted inline sub-walk hands back to the interpreter, +# for a call made inside a FOR_ITER body. +# +# When the walker gives up on inlining a callee it flushes the caller's frame +# at the CALL it entered under and lets the interpreter re-execute the whole +# call. That flush rebuilds the operand stack from two sources: the enclosing +# FOR_ITER's iterator (and anything else below the call) from the vstack +# mirror, and the call's own operands from the encoded residual op. The +# operands do not sit at a fixed byte offset in that op — the method-form CALL +# helpers lower through a shape whose leading Int list is variable-width, so a +# reader that assumes the plain shape's offset picks up the Int list's register +# indices and resolves them in the Ref bank. The result is a stack of the +# right HEIGHT (so the flush's depth check passes) holding the wrong objects: +# here the enclosing loop's iterator arrived as the subscript index, and +# `SubPattern.__getitem__` raised `TypeError: list indices must be integers or +# slices, not list_iterator`. +# +# `re.compile` of a large flat alternation is the reproducer: `_compile_info` +# calls `_get_charset_prefix`, whose BRANCH arm loops `for p in av[1]` and +# subscripts `p[0]` inside the body. The alternation has to be big enough for +# that loop to go hot — it is clean below about a thousand branches — and no +# hand-written class with the same shape has been made to reach the abort leg, +# so the real module drives it. +# +# The compile is the assertion: any wrong operand raises out of `re`. + +import re + +BRANCHES = 2000 + +pattern = "|".join("%d" % x for x in range(BRANCHES)) +compiled = re.compile(pattern) + +# Alternation is first-match, so "1999" is matched by the earlier "1" branch. +assert compiled.match("1999").group(0) == "1" +assert compiled.match("nope") is None + +print("OK") diff --git a/pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs b/pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs index b0a20cb398e..692aa32aee1 100644 --- a/pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs +++ b/pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs @@ -1300,7 +1300,14 @@ pub(crate) fn reconstructed_all_ref_call_stack( op: &DecodedOp, ctx: &WalkContext<'_, '_, Sym>, ) -> Option> { - let fresh = read_ref_var_list_concrete(code, op, 1, ctx); + // The Ref list is NOT at a fixed offset: the method-form `CALL` helpers + // this leg latches for lower through the mixed `iIRd>r` shape, whose + // leading Int list shifts it (`dispatch_residual_call_iIRd_kind` reads it + // at `1 + i_width`). Reading offset 1 there takes the Int list's register + // indices into the Ref bank — refs unrelated to the call, of a length that + // still passes the flush's depth check. + let ref_operand_offset = ref_var_list_operand_offset(code, op)?; + let fresh = read_ref_var_list_concrete(code, op, ref_operand_offset, ctx); if fresh.is_empty() { return None; } diff --git a/pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs b/pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs index 97fab478dd3..772173558c1 100644 --- a/pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs +++ b/pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs @@ -3947,6 +3947,44 @@ fn concrete_from_recorded_opref( } } +/// The `operand_offset` of this op's Ref var-list (`R`), for a reader that +/// holds only the decoded op and must find that list itself. +/// +/// A dispatcher that decoded the whole op passes the offset it already +/// computed; anything reached later — an abort leg, a specializer entered +/// after resolution — has no such value and must not guess one. `R` sits at +/// offset 1 for the Ref-only residual shape (`iRd>r`) but NOT for the mixed +/// one (`iIRd>r`, `riIRd>r`, `iiIRd>r`), whose leading Int list is itself +/// variable-width: reading offset 1 there lands on the Int list's length byte +/// and takes its register indices into the Ref bank, yielding unrelated +/// objects with a plausible length. +/// +/// Walks the argcode widths of `blackhole.py:112-157`, the same walk +/// [`decode_op_at`] performs. `None` — an op declaring no Ref list, or one +/// whose earlier operands cannot be width-counted — leaves the caller to +/// decline. +/// +/// [`decode_op_at`]: crate::jitcode_runtime::decode_op_at +fn ref_var_list_operand_offset(code: &[u8], op: &DecodedOp) -> Option { + let first_operand_pc = op.pc + 1; + let mut cursor = first_operand_pc; + let mut chars = op.argcodes.chars(); + while let Some(c) = chars.next() { + match c { + 'R' => return Some(cursor - first_operand_pc), + 'i' | 'c' | 'r' | 'f' => cursor += 1, + 'L' | 'd' | 'j' => cursor += 2, + 'I' | 'F' => cursor += 1 + *code.get(cursor)? as usize, + '>' => { + chars.next()?; + cursor += 1; + } + _ => return None, + } + } + None +} + /// Read concrete shadow values for a Ref-bank variadic operand list. /// Parallels [`read_ref_var_list`] — reads the /// same byte indices but resolves through `ctx.concrete_registers_r`. diff --git a/pyre/pyre-jit-trace/src/jitcode_dispatch/tests.rs b/pyre/pyre-jit-trace/src/jitcode_dispatch/tests.rs index 2ae807bbca5..be3e2342fc0 100644 --- a/pyre/pyre-jit-trace/src/jitcode_dispatch/tests.rs +++ b/pyre/pyre-jit-trace/src/jitcode_dispatch/tests.rs @@ -12276,3 +12276,50 @@ fn traceback_journal_rollback_unwinds_every_walk_node() { "a committed walk keeps its nodes" ); } + +#[test] +fn ref_var_list_offset_follows_the_argcodes_not_a_fixed_byte() { + // The Ref-only residual shape puts its `R` list right after the funcptr + // register, so offset 1 answers it; the mixed shape does not, because the + // Int list between them is variable-width. A reader that assumes 1 for + // both takes the Int list's length byte as the Ref list's, and its + // register indices into the Ref bank — a list of unrelated objects whose + // LENGTH still looks plausible, which is what let the wrong operand stack + // through the abort flush's depth check. + let ref_only = *insns_opname_to_byte() + .get("residual_call_r_r/iRd>r") + .expect("residual_call_r_r/iRd>r must be in insns table"); + // opcode, i=funcptr, R: len=2 + 2 regs, d=0x0001, >r dst + let code = [ref_only, 0x07, 0x02, 0x04, 0x05, 0x01, 0x00, 0x09]; + let op = crate::jitcode_runtime::decode_op_at(&code, 0).expect("must decode"); + assert_eq!(op.argcodes, "iRd>r"); + assert_eq!(ref_var_list_operand_offset(&code, &op), Some(1)); + + let mixed = *insns_opname_to_byte() + .get("residual_call_ir_r/iIRd>r") + .expect("residual_call_ir_r/iIRd>r must be in insns table"); + // opcode, i=funcptr, I: len=3 + 3 regs, R: len=2 + 2 regs, d, >r dst + let code = [ + mixed, 0x07, // i + 0x03, 0x00, 0x01, 0x02, // I: len=3 + 0x02, 0x04, 0x05, // R: len=2 + 0x01, 0x00, // d + 0x09, // >r + ]; + let op = crate::jitcode_runtime::decode_op_at(&code, 0).expect("must decode"); + assert_eq!(op.argcodes, "iIRd>r"); + assert_eq!( + ref_var_list_operand_offset(&code, &op), + Some(5), + "the R list starts past the 4-byte I list, not at offset 1", + ); + + // An op with no Ref list at all has no offset to answer with, and the + // caller must decline rather than read whatever sits at a guessed one. + let int_only = *insns_opname_to_byte() + .get("int_add/ii>i") + .expect("int_add/ii>i must be in insns table"); + let code = [int_only, 0x01, 0x02, 0x03]; + let op = crate::jitcode_runtime::decode_op_at(&code, 0).expect("must decode"); + assert_eq!(ref_var_list_operand_offset(&code, &op), None); +}