Repository navigation
jit(wasm): fix the entry-bridge decline chain — record inputarg types on the token, and publish a peeled loop's resume-label prefix - #996
Conversation
…xits `compile_entry_bridge` returns `false` from six places and MC_DIAG slot 31 counted them as one number. Slots 34-39 name each: no compiled loop at the target key, dead JitCellToken, `optimize_bridge` InvalidLoop, retrace requested, backend panic, backend `Err`. Slot 31 stays their total. `MAJIT_LOG`'s per-exit lines never reach a wasm run — the guest is built for wasm32-unknown-unknown, whose `std::env` is empty — so on that backend the counters are the only channel that can distinguish them. Measured on `str_search_index_bounds`: wasm `ct_entry_bridge_failed=34` resolves to `ceb_backend_err=34`, dynasm 0. Also print the `InvalidLoop` reason the `Err(_invalid_loop)` binding discarded, and note in the runner that its label list is a positional mirror of `MC_DIAG_LABELS` that must be appended to, never inserted into. Assisted-by: Claude
…e the entry bridge's cross-loop JUMP target `compile_loop` never called `token.set_inputarg_types`; the dynasm (`runner.rs:2269`) and cranelift (`compiler.rs:15613`) backends both do. `jitdriver.rs extend_compiled_live_values` reads that list to size the live-value list it hands `execute_token`, so on wasm it read 0 and skipped the virtualizable extension: a trace whose inputargs outnumber the portal's live values was entered with the short list, and every frame slot past it read as the zero-filled frame, which the module prologue loaded as a null Ref. `compile_loop` also declined every trace whose terminal JUMP has no local LABEL, because it passed `external_jump_slot = 0` while only `compile_bridge` resolved the target. Both callers now share `has_cross_loop_terminal_jump` and `resolve_cross_loop_jump_target`; `compile_loop` resolves an entry bridge's target through `LABEL_TARGETS`, adopts its frozen `FrameGeometry` after checking its own value slots and Ref homes fit, and passes the target's table slot and resume key to `build_wasm_module`. The two changes are one unit: with only the first, a comprehension inside a hot loop turns from a wrong result into a `blackhole::handler_raise` panic. `len([j for j in range(3)])` called 2000 times prints 6000 on wasm instead of raising `TypeError`. wasm `loops_aborted`: `str_search_index_bounds` 35 -> 1, `gc_bug_bridge_flavor_traceback_names` 26 -> 2, `exception_inline_callee_tb_frames` 7 -> 0, `gc_iterator_source_drop` 7 -> 0. `check.py --backend wasm` 1 failed / 363 passed, up from 2 / 362; dynasm 2 / 366 and cranelift 2 / 365 are unchanged, and every remaining failure reproduces on a build of the base. `cargo test -p majit-backend-wasm` 27 passed. Assisted-by: Claude
…tead of refusing every non-last-label backedge `is_resumable_peeled` returned false whenever the closing JUMP's target LABEL was not the trace's last one. `build_function` then emitted no resume dispatch and `compile_loop` published no `LABEL_TARGETS` entry for any of that loop's labels, so every bridge whose JUMP named one declined as unpublished. The restriction came from the wrapper's block structure: each label's (past_loader, loader) pair is opened before the wasm `loop`, so a pair belonging to a label inside the loop body would have to close inside the loop. That excludes the in-body labels, not the ones at or before the header. `resumable_label_count` names that prefix. `build_function` opens a pair, collects label args and emits a loader for exactly those labels; `LabelResumeData::collect` plans captures for them only; `compile_loop` publishes them and marks the header -- not the trace's last label -- as `is_last_label`. `bench/synth/exception_traceback_loop_forms.py` on wasm: loops_aborted 20 -> 0, ml_unpublished 21 -> 0, ceb_backend_err 19 -> 0, BRIDGE_OK 2 -> 4, pub_peeled 10 -> 12. A `--snapshot-diff` of the whole wasm corpus against a build of the same tree without this change reports identical numbers on every other fixture. `test_non_last_label_backedge_validates` pinned the old refusal; it now asserts the shape is peeled with one resumable label and still validates. The three `--ignored` runtime integration tests pass, including `global_reassign_retraces_non_last_label_backedge_at_runtime`, which runs this shape and compares wasm stdout against dynasm. Assisted-by: Claude
…fell The floor (`_jit_stats_regression_floor`) treats `loops_aborted` as a badness field, so it fails only on a rise. Every reduction therefore passes while leaving the recorded ceiling where it was: after the entry bridge work the committed baselines still permitted `exception_reraise_tb_depth_hot` to go back from 0 to 44 and `pickle_terminal_raise_resume` from 37 to 54 without failing. `check.py --snapshot --backend wasm` rewrote every baseline; the one fixture whose number ROSE, `exception_args_virtual` (`loops_aborted` 0 -> 3, a base-inherited red that reproduces on a build of this base with none of these commits), was restored from HEAD rather than recorded. All 26 remaining changes are decreases in `loops_aborted`; no other field moved. Attribution, measured with `--snapshot-diff` against a build of this tree with the backend change reverted: `exception_traceback_loop_forms` 20 -> 0 comes from 4e621148188, the other 25 from bdeb4ab10e2. Assisted-by: Claude
|
Warning Review limit reached
Next review available in: 37 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (33)
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. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2523f3fd95
ℹ️ 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".
| ops[..loop_label] | ||
| .iter() | ||
| .any(|op| op.opcode != OpCode::Label) |
There was a problem hiding this comment.
Base the advance census on the actual loop header
When the newly supported backedge targets a LABEL that precedes another in-body LABEL, guard_fail_args_advanced still starts its census after rposition(Label) (lib.rs:148-152) rather than after the LABEL selected by the terminal JUMP. Consequently, arithmetic between the real header and the later marker is not recorded; a loop-closing bridge that forwards such an advanced guard fail-arg is then classified as non-advancing at lib.rs:2616-2619 and permanently declined despite making progress. Use the same descr-based header lookup introduced here for this census.
Useful? React with 👍 / 👎.
…mapping mimalloc is pyrex's default global allocator (`pyre/pyrex/Cargo.toml:26`) and follows every successful `mmap` with `prctl(PR_SET_VMA, PR_SET_VMA_ANON_NAME, p, size, "mimalloc")` (`mimalloc/src/prim/unix/prim.c` `unix_mmap_prim`). `allowed_syscalls` listed `mmap` but not `prctl`, so the first region mimalloc reserved after `install_runtime_filter` trapped: `pyre: sandbox seccomp blocked syscall 157` (x86_64 `SYS_prctl`) and `_exit(159)` out of the SIGSYS handler. Whether that mapping happens before or after lockdown depends on how much the workload allocates, which is why it surfaced on `sandbox_blocks_known_escapes` — the probe printed `ALL-BLOCKED` and then died on the exit code. `prctl` is a multiplexer, so it is not added to the flat number list: `PR_SET_SPECULATION_CTRL` drops this process's Spectre mitigations and `PR_SET_DUMPABLE`/`PR_SET_PTRACER` open its memory to a sibling. The filter now checks `args[0]` for that one syscall and admits only `PR_SET_VMA`; any other option still traps and names itself through the same handler. Program construction moves into `build_filter_program` so the jump arithmetic — which now has three landing sites instead of two — is covered by tests that execute the emitted program over a synthetic `seccomp_data` rather than asserting on instruction indices. Assisted-by: Claude
🤖 Codex parity reviewStatic analysis of this diff vs the local RPython/PyPy sources (commit 463212d). Files in the reviewed diff1. Regressions to PyPy parity introduced by this patchNone. 2. Other mismatches introduced by this patch
3. Pre-existing mismatches (already present before this patch)None. 4. Structural adaptations
|
…ch moved `check.py --snapshot-diff` reported 53 rows, all `jit-stats diff` and none `snapshot output diff`, so no fixture's output changed. Recorded with `check.py --snapshot`; only `.jitstats` files moved. Direction of the 109 field moves, against the gate groups in check.py: 93 in the improving direction, 0 rises in a badness field, 0 falls in `loops_compiled`, and 16 rises in `guard_failures` — all inside the `base + max(base // 4, 2)` band the field is gated on. Twelve of the sixteen `guard_failures` rises accompany a `loops_compiled` rise or a `loops_aborted` fall in the same fixture, i.e. more of the frame compiles so more guards exist to fail: `attr_cache_invalidation` 802 -> 1002 with `loops_compiled` 1 -> 2, `exception_reentry_guard_finally_residual` 2044 -> 2261 with `loops_aborted` 5 -> 0 and `loops_compiled` 2 -> 3, `goto_if_not_same_box` 0 -> 2 with `loops_compiled` 0 -> 2, `blackhole_inlined_callee_local_after_escape` 0 -> 1 with `loops_aborted` 10 -> 5. The remaining four are +1 or +2 on cranelift. `loops_aborted` summed over the 53 recorded files: 120 -> 56. The wasm baselines are unchanged; main re-recorded them in #996. Assisted-by: Claude
…ch moved `check.py --snapshot-diff` reported 53 rows, all `jit-stats diff` and none `snapshot output diff`, so no fixture's output changed. Recorded with `check.py --snapshot`; only `.jitstats` files moved. Direction of the 109 field moves, against the gate groups in check.py: 93 in the improving direction, 0 rises in a badness field, 0 falls in `loops_compiled`, and 16 rises in `guard_failures` — all inside the `base + max(base // 4, 2)` band the field is gated on. Twelve of the sixteen `guard_failures` rises accompany a `loops_compiled` rise or a `loops_aborted` fall in the same fixture, i.e. more of the frame compiles so more guards exist to fail: `attr_cache_invalidation` 802 -> 1002 with `loops_compiled` 1 -> 2, `exception_reentry_guard_finally_residual` 2044 -> 2261 with `loops_aborted` 5 -> 0 and `loops_compiled` 2 -> 3, `goto_if_not_same_box` 0 -> 2 with `loops_compiled` 0 -> 2, `blackhole_inlined_callee_local_after_escape` 0 -> 1 with `loops_aborted` 10 -> 5. The remaining four are +1 or +2 on cranelift. `loops_aborted` summed over the 53 recorded files: 120 -> 56. The wasm baselines are unchanged; main re-recorded them in #996. Assisted-by: Claude
…ch moved `check.py --snapshot-diff` reported 53 rows, all `jit-stats diff` and none `snapshot output diff`, so no fixture's output changed. Recorded with `check.py --snapshot`; only `.jitstats` files moved. Direction of the 109 field moves, against the gate groups in check.py: 93 in the improving direction, 0 rises in a badness field, 0 falls in `loops_compiled`, and 16 rises in `guard_failures` — all inside the `base + max(base // 4, 2)` band the field is gated on. Twelve of the sixteen `guard_failures` rises accompany a `loops_compiled` rise or a `loops_aborted` fall in the same fixture, i.e. more of the frame compiles so more guards exist to fail: `attr_cache_invalidation` 802 -> 1002 with `loops_compiled` 1 -> 2, `exception_reentry_guard_finally_residual` 2044 -> 2261 with `loops_aborted` 5 -> 0 and `loops_compiled` 2 -> 3, `goto_if_not_same_box` 0 -> 2 with `loops_compiled` 0 -> 2, `blackhole_inlined_callee_local_after_escape` 0 -> 1 with `loops_aborted` 10 -> 5. The remaining four are +1 or +2 on cranelift. `loops_aborted` summed over the 53 recorded files: 120 -> 56. The wasm baselines are unchanged; main re-recorded them in #996. Assisted-by: Claude
… merge points, quasi-immut deps, front-entry split (#960) * majit-macros: take raw-store width and signedness from the intrinsic name `lower_raw_store_stmt` accepted only `majit_raw_store_i64` and minted `add_raw_int_array_descr(8)`, which is unsigned. It now recognizes the full `majit_raw_store_{i,u}{8,16,32,64}` table and mints `add_raw_int_array_descr_signed(item_size, is_signed)`, mirroring the load-side table at `lower_value.rs:471-505`. `jtransform.py:1156-1163 rewrite_op_raw_store` builds the descr from the stored value's own type, so width and sign come off the descr. All three backends and the blackhole already read `item_size` from the descr (`compiler.rs:12822`, `aarch64/assembler.rs:2900`, `codegen.rs:2739`, `runner.rs:3363`); the store path does not read signedness, so the i64 arm changing from unsigned to signed writes the same bytes. Assisted-by: Claude * majit: seed bridge input boxes with deadframe values, build live_arg_boxes once `start_retrace_from_guard` minted the bridge recorder's inputargs from types alone, and `compile_bridge_trace` rebuilt them the same way, so their concrete value slots were empty. resume.py:1267-1282 `load_box_from_cpu` constructs the frontend box as `IntFrontendOp(num, cpu.get_int_value(deadframe, num))`; `closing_jump_runtime_boxes` reads those values to build `runtime_boxes`, which virtualstate.py:493-498 consults. Stamp both sites from `fail_values` / `recorder.inputargs()`. `reached_loop_header` (pyjitpl.py:2974-2989) builds `live_arg_boxes` once and hands the same list to the bridge JUMP, the merge-point scan and `current_merge_points.append`. The CloseLoop arm built and normalized it twice; `remove_consts_and_duplicates` records a SAME_AS per rewrite, so the bridge fall-through path appended a second set after `compile_trace` had sampled `potential_retrace_position`. Hoist the construction above the bridge branch. `compile_retrace` never set `Optimizer::trace_inputargs`, so the Phase 2 TraceIterator seeded an empty cache (opencoder.py:264-267) and `_get` panicked on the first body operand referring to a cut inputarg. `resolve_to_operand` returned `inputarg_refs[idx]` without checking that the slot belongs to `idx`; slots grown by `resize_with(.., new_int(0))` hold an index-0 Int placeholder, so an out-of-range Ref position resolved to an Int box at position 0. Records in `compile_loop_body` why the merge-point scan stays a two-way test: after a cancelled close the walk emits a loop-close action at every pc it steps to, so upstream's third branch (append and keep tracing) does not terminate. pyre/check.py: dynasm 352/352, cranelift 352/352, wasm 348/348. Assisted-by: Claude * majit: key the loop-header stamp on the visited merge point's greens, port the partial-trace gate and the merge-point append pyjitpl.py:1553-1554 reads `ptoken = self.get_procedure_token(greenboxes)` where `greenboxes` are the greens of the merge point being visited. `dispatch.rs` keyed that lookup on the fixed `ctx.green_key` instead, so once the trace-start key had compiled targets the stamp re-armed at every merge point the trace reached and `reached_loop_header` ran on body merge points that upstream returns from at 1554. pyjitpl.py:3003 `if not self.partial_trace:` gates the bridge attempt; `jitdriver.rs` computed the value into `_has_partial_trace` and discarded it. pyjitpl.py:3018-3060 has three outcomes, not two: a same-greenkey merge point at `retracing_from` retraces, one elsewhere aborts, and NO same-greenkey entry falls through to `current_merge_points.append` with the history still live. `compile_loop_body` collapsed that to a two-way test and compiled the retrace in the same call, so the retrace body was `Label; Jump`. It now registers the merge point through `register_retrace_merge_point` and returns `Cancelled`; `MetaInterp::keep_tracing_after_close` tells the driver to keep `self.sym` so the walk records the next iteration, and the retrace closes on the following header visit. With CEL_RETRACE_LIMIT=5 on the cel poison reproducer the close census goes from one CloseLoop per element to 8 at pc=45 plus 1 at pc=27, and `compile_retrace` cuts a 28-op body instead of an empty one. Assisted-by: Claude * majit: drop the single unresolvable short-preamble op instead of the whole inline shortpreamble.py:128-139 `PureOp.add_op_to_short` (and its HeapOp / LoopInvariantOp siblings) returns None as soon as one arg's `produce_arg` returns None, and `add_op_to_short` (:311-341) then leaves THAT ONE op out of `produced_short_boxes`. Anything that depended on it drops in turn because its own `produce_arg` now misses. Upstream therefore reaches the inline with the filtering already done, which is why `ExtendedShortPreambleBuilder.setup` (:460-463) is three field assignments and cannot fail. `ExtendedShortPreambleBuilder::setup` cleared `short`/`short_results` and returned false on the first unresolvable arg, and the caller turned that into `invalid_loop`. Pyre's short boxes are exported against one label-arg set and replayed against another — a retrace runs no Phase 1 and imports the partial trace's exported state — so an arg that was produce-able at export can be unresolvable at setup. Skip that op and keep going; `short_results` already carries the transitive drop. On the cel poison reproducer with CEL_RETRACE_LIMIT=5 exactly one op is dropped (IntAdd at IntOp(241), whose arg InputArgInt(222) is not a body label arg), `jump_to_existing_trace` reports `jumped=true, invalid_loop=false` instead of falling back to jump_to_preamble, and the compiled retrace terminates instead of looping forever. Assisted-by: Claude * majit: re-enter the walk when a merge point only appends, instead of handing the guest back pyjitpl.py:1570-1578 calls `reached_loop_header` with `self.pc = orgpc` and, when it returns without raising (the `current_merge_points.append` path at :3058-3060), restores `self.pc = saved_pc` and keeps interpreting under the recorder. This driver returned from `merge_point` instead, and the `jit_merge_point!` expansion's `#pc = __sp_pc; continue;` handed the guest to the native dispatch loop, which executed the merge point's own instruction unrecorded. `merge_point` now loops on that path: it discards the walk-final handoff staged at the top of the CloseLoop arm, sets `TraceCtx::merge_point_resumed`, and re-enters the walk. The flag is read-and-clear; the dispatch merge-point arm consumes it and falls through once, and the macro wrapper's header-revisit fast path peeks at it so the resumed segment records the instruction rather than closing again with nothing between. `merge_point` and its delegate `jit_merge_point_keyed` take `FnMut`. pyre's bridge closure moved a non-Clone `FrameBox` in, so it snapshots the rooted frame per entry. Assisted-by: Claude * majit: drop the start label from compile_retrace's assembly compile.py:327-328 `compile_loop` assembles `[start_label] + preamble_ops + extra_same_as + extra_before_label + [label_op] + loop_ops`, with the start label FIRST. compile.py:381-382 `compile_retrace` assembles `loop.operations + extra_same_as + [label_op] + loop_ops` and emits no start label at all. majit's retrace reused the compile_loop assembly, so a second LABEL landed between the saved preamble ops and the loop label, declaring the pre-peel argument set for slots the loop label rebinds. `UnrollOptimizer::emit_start_label` defaults to true and is cleared by `compile_retrace`; `assemble_peeled_trace_with_jump_args` already emits the start label only when handed a descr. Assisted-by: Claude * majit: cancel a retrace whose closing JUMP does not target its own label compile.py:390 `target_token = loop.operations[-1].getdescr()`: the retrace's closing JUMP names the label the same trace installs. The other optimizer outcome, `unroll.py:238-242 jump_to_preamble`, retargets the JUMP at the original loop's start descr, which is a jump into another compiled trace — `x86/assembler.py:2461-2467 closing_jump` emits `JMP(imm(target_token._ll_loop_code))`. This backend compiles one function per trace and cannot emit that, so such a JUMP would name no label in the trace it lands in. compile_retrace now takes compile.py:368-371's cancel shape when the closing JUMP's descr is not the trailing label's. Assisted-by: Claude * majit: normalize the bridge virtualizable seed for consts and duplicates pyjitpl.py:2985-2987 runs `remove_consts_and_duplicates` over `virtualizable_boxes[:-1]`; the bridge seed builds that same list from resume data, where the reader cache can return one OpRef for two slots. Assisted-by: Claude * majit: persist reached_loop_header's virtualizable normalization into the ctx pyjitpl.py:2985-2988 runs `remove_consts_and_duplicates` over `self.virtualizable_boxes[:-1]` IN PLACE and then appends the mutated list to `live_arg_boxes`; the loop-carried list built here normalizes a copy, leaving `TraceCtx::virtualizable_boxes` unrewritten. Assisted-by: Claude * majit: dispatch compile_retrace on the resumekey class compile.py:392 ends compile_retrace with `resumekey.compile_and_attach(metainterp, loop, inputargs)`, and the two resumekey classes install the finished trace differently: ResumeGuardDescr (compile.py:797-809) sets `new_loop.original_jitcell_token = metainterp.resumekey_original_loop_token` and calls `send_bridge_to_backend` — the trace is attached to the guard it grew from, under the source loop's token. ResumeFromInterpDescr (compile.py:1006-1023) makes a new jitcell token, calls `send_loop_to_backend` and `attach_procedure_to_interp`. `compile_retrace` applied the second form to both: it always minted a JitCellToken, called `backend.compile_loop`, then `compiled_loops.swap_remove` + `insert` + `attach_procedure_with_redirect`. Fork on `bridge_info()`, which is `Some` exactly when the session started at `start_retrace_from_guard`. That arm goes to a new `attach_retrace_to_source_guard`, mirroring `compile_bridge`'s tail: `backend.compile_bridge(fail_descr, inputargs, ops, &source_jct, previous_tokens, caller_recovery_layout)`, then `record_loop_or_bridge(&source_jct, ..)` and `compiled_loops[green_key].traces.insert(bridge_trace_id, ..)`. No token mint, no `compiled_loops` key mutation, no warmstate attach. `patch_new_loop_to_load_virtualizable_fields` stays on the front-door arm only: compile.py:509-511 places that hook in `send_loop_to_backend`; `send_bridge_to_backend` (compile.py:569-576) has no counterpart. Target tokens are rebound to the source token's number (`propagate_original_jitcell_token`, compile.py:463-468) and recorded on `loop_jitcell_token` (unroll.py:290-297), which also carries `retraced_count` (unroll.py:213-215). `unroll.py:198/239/277` scans the same list it appends to, so the retrace's labels are written back into `front_target_tokens`; the write-back is order-preserving. check.py: dynasm 357/357, cranelift 357/357, wasm 353/353. cargo test -p majit-metainterp --release: 0 failed. Assisted-by: Claude * Revert "majit: normalize the bridge virtualizable seed for consts and duplicates" This reverts commit 326c040d005c158679ba21ca51421b117fdc86d4. `remove_consts_and_duplicates` has one upstream call site, pyjitpl.py:2980-2988 inside `reached_loop_header`, which normalizes `self.virtualizable_boxes` in place; nothing normalizes the list where a bridge rebuilds it from resume data. That in-place site is now covered by aa9538f17cc, so the seed-side call is both non-upstream and redundant. It also had no measured effect: with it, `cut-original-boxes` reports `dups=[]`, and the retrace's element-to-inputarg shift and the SEGV are unchanged. optimizer.py:889-894 `optimize_SAME_AS_I` does `make_equal_to(op, op.getarg(0))` ("These needs to always be optimized out"), so the `record_same_as` a normalization emits cannot survive optimization as a distinct box. check.py: dynasm 357/357, cranelift 357/357, wasm 353/353. cargo test -p majit-metainterp --release: 0 failed. Assisted-by: Claude * majit: stop clearing the resumekey when a retrace is requested `MetaInterp.bridge_info` is pyre's port of `self.resumekey`'s class: `Some` is a `ResumeGuardDescr` session, `None` a `ResumeFromInterpDescr` one. Upstream writes that field exactly twice, both at session start — pyjitpl.py:2905 `self.resumekey = compile.ResumeFromInterpDescr( original_greenkey)` and pyjitpl.py:2939 `self.resumekey = resumedescr` — and `retrace_needed` (pyjitpl.py:2438-2442) sets only `partial_trace`, `retracing_from`, `exported_state` and `heapcache.reset()`. The `BridgeCompileResult::RetraceNeeded` fall-through cleared it, so by the time `compile_retrace` read it for compile.py:392's `resumekey.compile_and_attach` dispatch it was always `None`. Every guard-originated retrace therefore took the `ResumeFromInterpDescr` install (compile.py:1006-1023: new jitcell token, `send_loop_to_backend`, front door) instead of compile.py:797-810's `send_bridge_to_backend`. The two differ in the entry contract, not just bookkeeping: a front door is fed positionally in loop-carried order, while a guard-originated trace's `loop.inputargs` are `partial_trace.inputargs` (compile.py:379 + :1084), minted in the parent guard's failarg order. The bridge attempt on this arm is already gated on `partial_trace` (jitdriver.rs:2918-2919, pyjitpl.py:3003 `if not self.partial_trace:`), so a retained `bridge_info` cannot re-enter it. cel `poison` (POISON_SHORT=2 POISON_LONG=8 CEL_RETRACE_LIMIT=5): the retrace now logs `attached retrace to guard`, no front door is installed, and ROWS 16/40/64/80/100/128/160/200/2000 all exit 0 where every ROWS previously ended in SIGSEGV. Not sufficient on its own: with the artifact reachable, phase3 checksums diverge from the `CEL_RETRACE_LIMIT=0` control whenever the retrace stays in compiled code (ROWS=80 phase3 deopts=1 -> 232 vs 24960; ROWS=64 phase3 deopts=127 -> correct). That is a separate defect in the retrace artifact itself, previously unobservable because the process died first. check.py: dynasm 358/358, cranelift 358/358, wasm 354/354. cargo test -p majit-metainterp --release: 0 failed. Assisted-by: Claude * majit: gate the CloseLoopWithArgs bridge attempt on partial_trace `pyjitpl.py:3003 if not self.partial_trace:` guards the whole bridge attempt in `reached_loop_header`. The `CloseLoop` arm applies it (jitdriver.rs:2918-2919); the `CloseLoopWithArgs` twin bound `partial_trace().is_some()` to a discarded `_has_partial_trace` and entered the arm unconditionally, with `take_bridge_info()` on `RetraceNeeded` standing in for the gate. Restore the gate and drop that `take`. `bridge_info` is the `self.resumekey` class discriminator; upstream writes it only at session start (pyjitpl.py:2905 / 2939) and `retrace_needed` (pyjitpl.py:2438-2442) writes none, so it has to reach `resumekey.compile_and_attach` (compile.py:392-393). `bridge_info` surviving `RetraceNeeded` means it no longer separates the bridge phase from the retrace phase, so the majit-only structural-decline predicate that permanently records a declined bridge guard now also tests `partial_trace().is_none()`. python3 ./pyre/check.py exit=0 — dynasm 358/358, cranelift 358/358, wasm 354/354. cargo test -p majit-metainterp --release: 0 failed. Assisted-by: Claude * majit: match retrace merge points on the greens the trace closes with `pyjitpl.py:3021 same_greenkey(original_boxes, live_arg_boxes, num_green_args)` compares a merge point's greens against the greens the trace is closing WITH. majit looked merge points up by `ctx.header_pc`, the header the walk last registered, and registered them under that same field. A trace closing on a different loop than the one the session last registered leaves that pc stale, so the scan matched a merge point whose greens differ from the close and ran `compile_retrace` on it. Upstream's `continue` falls out of the scan and appends (pyjitpl.py:3059-3060). Add `TraceCtx::close_header_pc`, which reads the guest pc out of `close_greens` (majit's `live_arg_boxes[:num_green_args]`) and falls back to `header_pc` when the walk recorded none, and use it for both the lookup and the registration. Measured on the cel `nums.map(y, y * 2)` retrace reproducer (POISON_SHORT=2 POISON_LONG=8, CEL_RETRACE_LIMIT=5 against the CEL_RETRACE_LIMIT=0 control). That workload registered merge points at guest pc 45 and 89 under one green key, and closed the pc-89 one with greens naming pc 27; the retrace grown there was attached to the pc-45 loop's guard. phase3 returned checksum 232 — row 0 only, every later row emitting a zero-length list — at ROWS 80, 128, 160, 200 and 2000. All nine row counts (16 40 64 80 100 128 160 200 2000) and all six CEL_RETRACE_LIMIT values 0..5 now match the control. ROWS=2000 phase3: 59.50 ns/row at limit=0, 34.10 ns/row at limit=5. python3 ./pyre/check.py exit=0 — dynasm 358/358, cranelift 358/358, wasm 354/354. cargo test -p majit-metainterp --release: 0 failed. Assisted-by: Claude * majit: carry the preamble's quasi_immutable_deps into the retrace `compile.py:384-390` builds a retrace's `quasi_immutable_deps` from two sources — `loop_info.quasi_immutable_deps` first, then `start_state.quasi_immutable_deps`. majit's `compile_retrace` wrote only the `loop_info` half (`std::mem::take(&mut unroll_opt.quasi_immutable_deps)`) because `ExportedState` had no such field. - `ExportedState` gains `quasi_immutable_deps`, populated at `export_state` from the exporting `Optimizer` (`unroll.py:106` / `unroll.py:234`). - `merge_quasi_immutable_deps` becomes `pub(crate)`. - `compile_retrace` clones the deps off `start_state` before it is moved into `unroll_opt.imported_state`, and merges loop_info-then-start_state into the value both installation arms (front door and `attach_retrace_to_source_guard`) store in `last_quasi_immutable_deps`. `cargo test -p majit-metainterp --release`: 1515 passed, 0 failed. `pyre/check.py`: exit 0. Assisted-by: Claude * majit: store the Cranelift front-entry choice instead of rescanning the target list `CompiledEntry::front_target_tokens` carried two roles. One is upstream's `JitCellToken.target_tokens` (history.py:440), which is only scanned (unroll.py:198/239/277/325) and appended to (unroll.py:297). The other has no upstream counterpart: `selected_front_target_token` rescanned the same list for "first non-preamble, else first" to name the LABEL the Cranelift host enters through, so appending a newly specialized label could move that choice. - `CompiledEntry` gains `front_entry_index: Option<usize>`, computed by `front_entry_index_for` with the same predicate the rescan used. - Every non-test install site and the three post-hoc list assignments populate it; the two fill-when-empty paths only set it when it is still `None`. - `selected_front_target_token` reads the stored index; `selected_front_target_token_from_slice` is deleted and `compact_label_values_for_selected_target` resolves through `front_entry_index_for`. - An index, not a cloned `TargetToken`: the list is mutated in place by the `walk_const_ptr_refs_mut` GC walk. Role-A sites are unchanged: `optimize_bridge`'s `&mut Vec` parameter, the GC walk, `has_compiled_targets`, and the `.first()` tentative JUMP descr. `cargo test -p majit-metainterp --release`: 1515 passed, 0 failed. `pyre/check.py`: dynasm 358/358, cranelift 358/358, wasm 354/354. cel poison ladder (9 row counts, CEL_RETRACE_LIMIT=5 vs 0): all match; ROWS=2000 phase3 50.5 ns/row at limit=0, 29.7 at limit=5. Assisted-by: Claude * majit: latch a declined bridge attempt separately from the resumekey `MetaInterp::bridge_info` is majit's `self.resumekey`. Upstream writes it only at session start (pyjitpl.py:2905 / 2939) and never clears it — `reached_loop_header` (pyjitpl.py:2979-3060) does not touch it, and `compile.py:392-393 resumekey.compile_and_attach` dispatches on it at the very end. majit cleared it on four fall-through-to-`compile_loop` paths (the `BridgeCompileResult::Declined` arm and the "no targets" branch of each of `CloseLoop` / `CloseLoopWithArgs`) to express a session PHASE, destroying the resumekey CLASS as a side effect. Four later readers dispatch on that class and were miscategorising a declined session: - pyjitpl.rs `compile_retrace` reads `bridge_info_cloned()`; with it taken the retrace installs as a new front door instead of `attach_retrace_to_source_guard` — the same defect 10916ce fixed on the `RetraceNeeded` arm, still reachable through `Declined`. - `compile_finish_from_active_session` takes the root FINISH branch (pyjitpl.py:3216/3242 dispatches on resumekey class only). - `jitcode_dispatch/mod.rs` routes to `compile_trace_from_interp`. - `trace_opcode.rs` falls back to `ctx.inputarg_types()`. `JitDriver::bridge_attempt_declined` now carries the phase, with the same lifecycle as its sibling `bridge_body_start_op_count` plus a reset on the `AbortPermanent` arm, which that sibling lacks. The three phase readers keep their exact answers: `bridge_info().is_none()` becomes `is_none() || bridge_attempt_declined`, and the `setup_aborted_bridge_descr` predicate and the two `has_targets` gates gain `!bridge_attempt_declined`. `is_bridge_tracing` is left reporting the class. Both its consumers want a live session, but `self.sym` is not that signal: `start_bridge_tracing` sets `bridge_info` and runs `rebuild_from_resumedata` before `self.sym = Some(sym)`, so the window opens while `sym` is `None`. The comment now records this. MC_DIAG grows to 38 slots: `bridge_declined_close`, `bridge_no_targets_close`, `abort_after_declined`, and `ct_compile_bridge_false` for the `compile_bridge`-returned-false producer, which had no tally. Reachability, `PYRE_MC_DIAG=1` over 350 synth fixtures run directly (check.py prints child stderr only for failing runs, so it cannot census this): bridge_declined_close 3 exception_args_virtual bridge_no_targets_close 7 7 fixtures, one each ct_compile_bridge_false 6 exception_args_virtual abort_after_declined 0 The latched paths are live, so the four calls were not dead. No session that declined went on to abort in this corpus, so the two phase-reader predicates are exercised only in their behaviour-preserving form. `cargo test -p majit-metainterp --release`: 1515 passed, 0 failed. `pyre/check.py`: failure set byte-identical to the same-HEAD baseline without this change (47 entries; 17 dynasm / 19 cranelift / 11 wasm, all from the branch base). Assisted-by: Claude * majit: reindent the merge-point walk body in jitdriver.rs 8cc2aaf wrapped the body in `loop { ... break; }` without re-indenting it, so `cargo fmt --all -- --check` reported a diff from line 2614 on. Assisted-by: Claude * majit: guard the short-preamble per-op drop and latch the RetraceNeeded gate `ExtendedShortPreambleBuilder::setup` drops a single unresolvable op the way `add_op_to_short` leaves one out (shortpreamble.py:283-296, 311-341), but `short_preamble.ops` and `short_preamble.jump_args` are filled in lockstep, so a dropped op whose result the short preamble's own JUMP carries leaves `short_jump_args` naming a position `inline_short_preamble` never inserts into `mapping` — `unroll.rs:3920`'s `expect("mapping missing jump_arg")`. Restore the whole-inline decline for that case only. The two `BridgeCompileResult::RetraceNeeded` arms lost `take_bridge_info()` so the resumekey class survives into `compile_retrace`; that also left the bridge gate open. Upstream closes it through `partial_trace` (pyjitpl.py:3003), armed by an unconditional `retrace_needed` (compile.py:1079), while majit arms it only when `compile_bridge` exported a state (pyjitpl.rs:11823). Set the attempt latch on both arms. `compile_retrace`'s closing-JUMP check said the backend cannot emit a `jump_to_preamble` JUMP. Cranelift emits one as an external exit (compiler.rs:13247-13302) and wasm has the same path (codegen.rs:2195-2229). The real blocker is that wasm dispatches on `has_loop` (codegen.rs:1983): a retrace has its own LABEL, so `find_loop_label_index`'s trailing-label fallback makes `has_loop` true and `find_label_args` turns the preamble JUMP into a back-edge to this trace's own label. Comment corrected; check unchanged. Assisted-by: Claude * majit: drop the positional deadframe stamp in start_retrace_from_guard 421d070 added a loop stamping every `recorder.inputargs()` slot from `fail_values` by position. `compile_bridge` already stamps the bridge input boxes from the deadframe, zipping `liveboxes` with `frontend_boxes` under `bridgeopt.py:126 assert len(frontend_boxes) == len(liveboxes)` — the live-filtered form `resume.py:1267-1282 load_box_from_cpu` has, since it runs once per live box while `rebuild_from_resumedata` walks the resume data, not once per `fail_arg_types` slot. The positional loop also gave a value to slots the resume data never resurrected. A dead Ref slot then read back as the constant NULL, and `virtualstate.py:400-405` found it equal to a target label's LEVEL_CONSTANT and appended a GUARD_VALUE pinning it. Measured on synth/defaults_reassigned_midloop (dynasm): 4 extra GUARD_VALUEs, two of them on `ptr(0x0)`; guard_failures 404 -> 812, bridges_compiled 2 -> 4, loops_compiled 3 -> 4. Bisected to 421d070 — 37b2d78 reports 404. Restores the committed `.jitstats` baselines on both dynasm and cranelift for the five fixtures CI reported: defaults_reassigned_midloop, mapdict_unboxed_type_change_attr, callee_store_global_read_after_call, foriter_inplace_immutable, inline_callee_constructs_object. cargo test -p majit-metainterp --release: 1515 passed, 0 failed. Assisted-by: Claude * majit: decline the short-preamble inline when the unresolvable op is a guard `ExtendedShortPreambleBuilder::setup` drops a single op whose args cannot be resolved and keeps inlining the rest. `short_preamble.ops` holds `is_guard() || is_always_pure()` ops, and a guard has no result box, so it can never appear in `short_preamble.jump_args` — the arity test added in the previous commit cannot reach it. A guard was therefore droppable, and the closing JUMP would enter a body optimized under that guard's fact without the guard being replayed. Upstream has no drop at this point. `ExtendedShortPreambleBuilder.setup` (shortpreamble.py:460-463) is three field assignments, and `inline_short_preamble` (unroll.py:372-410) replays every op in `short`, guards through their own `sop.is_guard()` arm at :405-409 which restamps each with a `ResumeAtPositionDescr`; `_map_args` (unroll.py:364-370) raises KeyError rather than dropping. The per-op filtering the comment cited (shortpreamble.py:128-139, :311-341) runs at EXPORT, before the peeled body is optimized against the short boxes. The comment claimed that export-time rule as the inline-time outcome; corrected here. The drop is now bounded to a pure producer whose result the short preamble's own JUMP does not carry. A guard, or a producer the JUMP carries, declines the whole inline so `jump_to_existing_trace` falls back to `jump_to_preamble` (unroll.py:154/167). Census: the unresolvable path fires 0 times across 371 pyre/bench fixtures (MAJIT_LOG=1 against target/release/pyre-dynasm), so this changes nothing pyre reaches today. `setup` sees a non-coincident arg set only on a retrace close, which `retrace_limit = 0` (rlib/jit.py:595) keeps dormant. cargo test -p majit-metainterp --release: 1515 passed, 0 failed. The five .jitstats fixtures from the previous commit are unchanged. Assisted-by: Claude * majit: decline the short-preamble inline when the unresolvable op is an ovf producer `extract_short_preamble` admits an `is_guard_overflow()` entry only as the paired successor of an included `is_ovf()` producer, and pushes the producer first, so the two sit adjacently in `short_preamble.ops`. The guard entry is argless, so `setup`'s resolvability test is vacuously true for it and it survives when its producer is dropped for an unresolvable arg. The `is_guard()` arm added in fbd2ac4 tests the op's own args and cannot see this pairing. An orphaned GUARD_NO_OVERFLOW reaches `optimize_guard_no_overflow` (intbounds.rs:737), which reads `last_emitted_opcode`: with no ovf op before it the guard is removed, and with an unrelated ovf op before it the guard attaches to that op and registers its inverse pure operations (intbounds.py:222-228) as facts about the wrong values. Upstream has no orphan because it never stores the two separately: `use_box` (shortpreamble.py:398-400) appends `ResOperation(rop.GUARD_NO_OVERFLOW, [])` immediately after appending an `is_ovf()` op. majit's `use_box` (shortpreamble.rs :3108) and `insert_dep_recursive` both do the same; only `setup`'s main loop reads the pair back as two entries. Declining rather than dropping both: GUARD_NO_OVERFLOW's postprocess registers inverse pure operations that the target body may already have been optimized under, so dropping the pair loses facts the same way dropping the guard alone does. cargo test -p majit-metainterp --release: 1515 passed, 0 failed. pyre/check.py --backend dynasm: failure set byte-identical to the parent commit (16 entries). Assisted-by: Claude * majit: correct the bridge-inputarg value-channel comment in compile_trace_inner The comment said the values on the recorder's inputargs are the ones `start_retrace_from_guard` seeded from the deadframe. That seeding was removed in 5c1ec82, so the citation names a channel that no longer exists. The values that reach this copy are written by the frontend through `TraceCtx::try_set_opref_concrete`, once per box the resume rebuild resurrects. Also record what `compile_bridge`'s `PendingBridgeRd` zip actually is: `prd.liveboxes` is built from every bridge inputarg (pyjitpl.rs:11581) and zipped positionally with the raw `fail_values`, so it is not the live-filtered form `bridgeopt.py:126` is cited for -- that line is a length assert. It also runs after `closing_jump_runtime_boxes` reads the inputargs, which is why the removed trace-start stamp changed `runtime_boxes` and this one does not. Comment only; no code change. Assisted-by: Claude * majit: decline compile_retrace when the merge-point re-lookup misses instead of assembling an uncut trace `compile_retrace` re-derives the merge point with `ctx.get_merge_point_at(green_key, ctx.header_pc)` and, on a miss, fell through to `else { trace }` and assembled the trace uncut. `combined_ops` still prepends `partial.ops`, which covers [0, retracing_from), to a body optimized from position 0, and `root_inputargs` still comes from `partial.inputargs` while the body references the recorder namespace. compile.py:347 `trace = metainterp.history.trace.cut_trace_from(start, inputargs)` is unconditional: `start` is read once at the caller's single merge-point selection (pyjitpl.py:3019) and handed straight down, so there is no upstream "merge point not found" state for the fallback to mirror. Nothing downstream rejects the uncut shape. `normalize_root_loop_entry_contract` (pyjitpl.rs:747-796) compares LABEL against JUMP arity within the same optimized output -- both minted by the same peel -- and returns `root_inputargs` untouched. The closing-JUMP cancel (`closes_on_own_label`, pyjitpl.rs:7581) rejects only foreign-target closes, which a self-close is not. 65aaf8d records a wrong-cut retrace passing both gates, attaching, and executing with divergent checksums. The caller reads `false` as pyjitpl.py:3004 "creation of the loop was cancelled", which is the outcome it already handles for every other decline in this function. The re-lookup itself should not exist; the caller selected this merge point under `close_header_pc()` and this reads `header_pc`. Threading the selected merge point down is tracked separately, as is the `mp.position._pos > 0` conjunct, which has no upstream counterpart -- pyjitpl.py:2902 seeds `current_merge_points = [(original_boxes, (0, 0, 0, 0, 0))]`, so position 0 is a valid selection upstream. cargo test -p majit-metainterp --release: 1515 passed, 0 failed. pyre/check.py --backend dynasm: failure set byte-identical to the parent commit (16 entries). `retracing_from` is armed only by `retrace_needed`, gated on `retrace_limit = 0` (rlib/jit.py:595), so pyre does not reach this path. Assisted-by: Claude * majit: tear the session down on the post-take retrace abort arm `compile_retrace` consumes `self.tracing`, so every `false` it returns after that point reaches the caller's `if self.tracing.is_none()` arm. That arm returned `CompileOutcome::Aborted` without touching the session, unlike the two sibling abort arms beside it which run `clear_retrace_state` + `warm_state.abort_tracing` + `clear_trace_session`. The driver's `Aborted` arm calls `abort_trace(false)`, and `abort_trace_live`'s whole body sits inside `if let Some(ctx) = self.tracing.take()` — already `None` here, so all of it is skipped and nothing calls `clear_trace_session`. That leaves `active_trace_session` and `bridge_info` set (pyjitpl.rs:2992-2993 are their only clear sites) and `profiler_tracing_active` true, because `leave_profiler_tracing` runs only from `clear_trace_session`. The next trace start calls `enter_profiler_tracing`, whose `assert!` on that flag is a release assert, so the process aborts on a later trace rather than here. The arm now mirrors its siblings. `green_key` is captured at the call site before `compile_retrace` takes the ctx. `retracing_from` is also now required rather than degraded: `compile.py:341-347` takes `start` as a parameter and has no `compile_retrace` without one, so the `else { trace }` arm is unreachable and can be deleted when the merge-point selection is threaded down from the caller. The arm predates this branch; the preceding commit made it the standard exit for a merge-point re-lookup miss. cargo test -p majit-metainterp --release: 1515 passed, 0 failed. pyre/check.py --backend dynasm: failure set byte-identical to the parent commit (16 entries). Assisted-by: Claude * majit: assert the virtualizable element block is a suffix of live_arg_boxes The write-back skipped `adopt_normalized_virtualizable_elements` whenever the element count exceeded `live_arg_boxes.len()`. Upstream has no such state: `pyjitpl.py:2985-2988` normalizes `self.virtualizable_boxes` in place and then appends it, so every later reader sees the rewrite. Skipping leaves the trace ctx's copy unnormalized, and a later cut mints a LABEL whose collapsed slots are missing -- the artifact 7e305fc added this write-back to prevent. `adopt_normalized_virtualizable_elements` (trace_ctx.rs:2203) already asserts the same invariant from the other side. The invariant holds by construction: `collect_jump_args_with_boxes` appends the element block to the loop-carried list, and `remove_consts_and_duplicates_untyped` takes `&mut [OpRef]`, so it substitutes SameAs ops in place and cannot shorten it -- matching `pyjitpl.py:2958-2964`, which assigns `boxes[i]` rather than removing. The `n > 0` short-circuit is kept: it skips a call that would copy zero elements. cargo test -p majit-metainterp --release: 1515 passed, 0 failed. pyre/check.py --backend dynasm: the assert does not fire on any of the 366 fixtures, and the failure set is byte-identical to the parent commit (16 entries). Assisted-by: Claude * majit: publish the abort image's operand stack from the walker's OpRef mirror `capture_frame_stack_for_publish` reads `locals_cells_stack_w`, which a root walk never writes: `setarrayitem_vable_via_metainterp` mirrors into a concrete frame only through `current_inline_vable_target`, i.e. only for a frame an INLINE level owns. Measured at the single adopt in `list_length_hint_validate`, the slot array reads `[0]` while the walker's mirror holds the live operand. Resolve the stack from `WalkContext::vstack_boxes` instead (`capture_frame_stack_from_mirror`), keyed on `vstack_cur_pypc`. Convert a walk abort into a blackhole resume only for the classes whose MIFrame image is complete (`DispatchError::leaves_complete_image`). Every RPython `SwitchToBlackhole` is a stop-tracing decision taken with the registers bound, which is why `_copy_data_from_miframe` (`blackhole.py:1713-1730`) copies the banks unconditionally and has no failing path. The walker resolves registers lazily against the live trace context, so a second family exists where the abort IS the report that a value is unavailable (`RegisterReadUnbound`, `*NotConcrete`); converting one resumes on the hole the walker refused to read, and broke `list_length_hint_validate` and `raise_reg_unbound_jitstress`. `WalkEndCommitLeg::WalkAbort` carries the leg and `PYRE_WALKABORT_OFF` disables it. 353 synth fixtures produce identical output with the leg on and off. Assisted-by: Claude * majit: forward the abort image mirror stack as a GC root, and stand aside for the qmut leg The walk-abort mirror stack is resolved to concrete refs when the abort latches and then held in a plain `Vec` until the walk-end adopter publishes it. That is the same collector-invisible window as the latched MIFrame Ref bank beside it, which `fbw_store_journal_walk_roots` already forwards, so forward the mirror slots there too. `flush_with_latched_stack` does not need this because it roots its stack (`push_resume_ref_roots`) for the duration of one flush call; this image outlives the call that builds it. `ForceQuasiImmutable` is the counterpart of `pyjitpl.py:1116` `raise SwitchToBlackhole(Counters.ABORT_FORCE_QUASIIMMUT)`, taken with the registers bound after `do_force_quasi_immutable` ran, so classify it in `leaves_complete_image`. Exclude it at the general adopt site instead: it owns `flush_qmut_abort_state`, which resumes AT the forcing opcode rather than finishing the frame past it. Assisted-by: Claude * majit: rustfmt publishes_root_stack, and re-cite the walk-abort gate against the current corpus `cargo fmt --all -- --check` reported the `publishes_root_stack` binding. The gate comment cited `raise_reg_unbound_jitstress`, which no longer reproduces: #973 refuses the frame when a live color has no concrete, and that covers the failure the comment described. Re-measured with the classification removed, 351 of 353 synth fixtures are unchanged and two break — `list_length_hint_validate` (its one `RegisterReadUnbound` walk, pc=456 reg=3 bank=r, underflows the operand stack) and `getframe_while_subwalk_decline_shapes` (an inline-escape shape whose caller banks are incomplete, dying on an unwired blackhole opcode; the fixture exists to pin that decline). Assisted-by: Claude * jit: reuse catch-landing edge exc_value instead of a producerless fresh Ref The catch-landing exception-value push allocated a fresh Ref Variable with no defining op. Regalloc treats a producerless Ref as dead-until-first-use and coalesces it onto a body colour (a local live across the `try`). On a guard-failure bridge that re-walks the handler, that body colour's def sits in the skipped prefix, so the pushed value is read unbound (RegisterReadUnbound), aborting the walk. Reuse the landing's own last_exception value Variable (the edge pair set by exception_landing_state, whose colour generate_last_exc writes) as the pushed value. It is re-derived on any bridge and interferes with the surrounding live ranges, so it earns a distinct colour. Same fix as the PUSH_EXC_INFO last_exc_value re-read. Restores list_length_hint_validate to its recorded jit-stats baseline (loops_aborted 34->14, guard_failures 4923->828); raise_reg_unbound_jitstress unchanged. dynasm synthetic gate: 352 passed, only exception_args_virtual red. Assisted-by: Claude * majit: mint the rebuilt bridge inputargs from types alone again `compile_bridge_trace` copied each recorder inputarg's concrete value onto the freshly minted `InputArg`. The channel that writes those values is not resume-walked: pyre's bridge setup stamps `try_set_opref_concrete` over every Ref input arg positionally, `Value::Ref(0)` included, while resume.py:1267-1282 `load_box_from_cpu` constructs a box only for a live resume entry. A dead Ref slot therefore reached `closing_jump_runtime_boxes` as a constant NULL and the NULL folded into the trace as an operand: the dynasm backend emitted `mov x0, #0` for the `jit_next` FOR_ITER residual, so `baseobjspace::next` dereferenced NULL. Measured on dynasm / macOS aarch64 with `lib-python/3/test/test_strftime.py` under `MAJIT_STRICT=1 MAJIT_STATS=1`: 3/27 runs SIGSEGV with the copy, 1/120 without it, `loops_compiled` 21 and `bridges_compiled` 21 -> 20. pyre/check.py: dynasm 370/370, cranelift 370/370, wasm 365/366 (the remaining `synth/pickle_terminal_raise_resume` `loops_aborted 54 -> 59` failure is unchanged from before this commit). Assisted-by: Claude * bench: re-record the 53 dynasm/cranelift jit-stats baselines the branch moved `check.py --snapshot-diff` reported 53 rows, all `jit-stats diff` and none `snapshot output diff`, so no fixture's output changed. Recorded with `check.py --snapshot`; only `.jitstats` files moved. Direction of the 109 field moves, against the gate groups in check.py: 93 in the improving direction, 0 rises in a badness field, 0 falls in `loops_compiled`, and 16 rises in `guard_failures` — all inside the `base + max(base // 4, 2)` band the field is gated on. Twelve of the sixteen `guard_failures` rises accompany a `loops_compiled` rise or a `loops_aborted` fall in the same fixture, i.e. more of the frame compiles so more guards exist to fail: `attr_cache_invalidation` 802 -> 1002 with `loops_compiled` 1 -> 2, `exception_reentry_guard_finally_residual` 2044 -> 2261 with `loops_aborted` 5 -> 0 and `loops_compiled` 2 -> 3, `goto_if_not_same_box` 0 -> 2 with `loops_compiled` 0 -> 2, `blackhole_inlined_callee_local_after_escape` 0 -> 1 with `loops_aborted` 10 -> 5. The remaining four are +1 or +2 on cranelift. `loops_aborted` summed over the 53 recorded files: 120 -> 56. The wasm baselines are unchanged; main re-recorded them in #996. Assisted-by: Claude * jit: trace newbool as a truth guard plus the prebuilt bool, not a residual call The six compare/bitwise specializations boxed their result with `emit_trace_bool_value_from_truth`, which records a `CallR(jit_bool_value_from_truth)` carrying `EffectInfo::new(CannotRaise, None)`. That effect set is not pure, so no optimizer pass removes the call even when the box is dead; on `pyre/bench/raise_catch_loop.py` two survived per iteration. `baseobjspace.py:896-900 newbool` returns one of the prebuilt `boolobject.py:79-80` singletons, and `pyjitpl.py:511-534` records that branch as `GUARD_TRUE`/`GUARD_FALSE` plus a promoted constant (`pyjitpl.py:525-526 replace_box`). `walker_newbool_guarded` emits that shape: the directional guard over the raw truth, `w_bool_from(observed)` as a Const ref, and `bool_box_truth_record` against the promoted Int so the following `is_true` folds. The residual box stays as the fallback. The new emission is restricted to the shape the forward JitCode lookahead already recognizes, so it is split out of `compare_box_provably_dead`: `classify_compare_box_use` returns `CompareBoxUse::FeedsBranchOnly` for conditions 1-3 (single reader, that reader is the `is_true` residual, scan terminates at `goto_if_not`) and carries condition 4 (`dst_reg`'s color dead at both arms) as a field. `compare_box_provably_dead` keeps requiring both; the guarded emission requires only the shape. `raise_catch_loop` traces 64 ops -> 44 after opt with no `CallR`, and its recorded `loops_compiled=1 bridges_compiled=1 loops_aborted=0 guard_failures=201` is unchanged. check.py --snapshot-diff: 370/370 dynasm, 370/370 cranelift, 366/366 wasm, jit-stats diff clean. Assisted-by: Claude * bench: size exc_in_loop_divzero_continue and finally_bare_raise above the exec floor `check.py` floors each side's startup-subtracted exec at `EXEC_TIME_FLOOR_S` (0.005s off Windows, check.py:112). Measured startup-subtracted pypy exec was 0.0018s for `exc_in_loop_divzero_continue` (N=120000) and 0.0025s for `finally_bare_raise` (N=2000000), so both ratio gates were dividing by the floor. The ratios they reported, 4.7x and 4.3x on dynasm, were 12.8x and 8.4x against real work, and a startup sample landing high moved the reported number without any change in pyre's own time — the wandering single-OS red that check.py:104-110 already describes for the startup half of the same divisor. N goes to 6000000 and 32000000. pypy exec is then 0.036s and 0.032s, and the ratios are 2.1x/2.5x and 1.8x/2.7x (dynasm/cranelift) against gates of 20 and 17; both fall rather than rise with size, so the compiled tier amortizes the added work. No gate number is changed. Cost: cpython 0.73s/1.13s, pyre 0.13s/0.12s, wasm 0.17s/0.12s. `exc_in_loop_divzero_continue`'s recorded jitstats move with the workload: guard_failures 360 -> 603 and bridges_compiled 0 -> 3 for 50x the iterations (sub-linear, i.e. the compiled loop holds), with loops_compiled=3, loops_aborted=0, internal_compile_panics=0 and descr_set_* unchanged. `finally_bare_raise`'s baseline is unchanged. Assisted-by: Claude * pyre-jit-trace: name try_walker_inline_builtin_call's four declines under the inline diag The builtin-helper inline path returned Ok(None) silently at each of its four screens. Print which one fired, the callable's type, and the BuiltinCode.func address, so a builtin missing from the `builtin_wrapper_indirect_graphs` PBC family is distinguishable from a non-Function callable. Assisted-by: Claude
Two wasm-backend defects that together kept the entry-bridge path from ever compiling, plus the diagnostic split that located them and a baseline re-record.
1.
compile_loopnever recorded the trace's inputarg typesmajit-backend-wasm'sBackend::compile_loopwas missingwhich both native backends do (
majit-backend-dynasm/src/runner.rs:2269,majit-backend-cranelift/src/compiler.rs:15613).jitdriver.rs extend_compiled_live_valuessizes the live-value list handed toexecute_tokenfrom that list. On wasm it read0, so the virtualizable extension was always skipped and a trace whose inputargs outnumber the portal's live values was entered with the short list:execute_tokenwrote onlyargs.len()words atFRAME_SLOT_BASE + k, the module prologue loadedinputargs.len(), and every slot past the args read the zero-filled frame — a null Ref per Ref input, surfacing far away inside a blackhole resume. Upstream basis:warmstate.py:188 cell.loop_token.This also closes a long-open wasm bug whose memo had concluded it was a metainterp vable-sync defect. Its 8-line repro (
len([j for j in range(3)])in a hot loop) now prints6000instead of raising.The same commit lifts⚠️ The two halves must land together — the one-liner alone turns the comprehension repro from a wrong answer into a
compile_loop's blanket decline of a terminal JUMP with no local LABEL: it now shareshas_cross_loop_terminal_jump/resolve_cross_loop_jump_targetwithcompile_bridge, resolves the entry bridge's target throughLABEL_TARGETS, and adoptsLabelTarget::frameafter checking its own value slots and Ref homes fit.blackhole::handler_raisepanic.2. The peel predicate refused a prefix it could have kept
is_resumable_peeledreturned false whenever the closing JUMP's target LABEL was not the trace's last one, sobuild_functionemitted no resume dispatch andcompile_looppublished noLABEL_TARGETSentry for any of that loop's labels — every bridge naming one then declined as unpublished.The underlying constraint is real but per-label: each label's
(past_loader, loader)pair is opened before the wasmloop, so a pair belonging to a label inside the loop body would have to close inside the loop. That excludes the in-body labels, not the ones at or before the header.resumable_label_countnames that prefix;build_function,LabelResumeData::collectandcompile_loopall work over it, and the header — not the trace's last label — is whatis_last_labelnow marks.Measured (wasm
loops_aborted)exception_reraise_tb_depth_hotstr_search_index_boundstrace_too_long_inline_multiframegc_bug_bridge_flavor_traceback_namesexception_traceback_loop_formsgc_deque_backing_listblackhole_inlined_callee_local_after_escapeexception_loop_warmupexception_inline_callee_tb_frames/gc_iterator_source_drop/foriter_call_resume_drops_iterationpickle_terminal_raise_resume26 fixtures in all, every one a reduction. Attribution comes from
check.py --snapshot-diff --backend wasmrun twice — once as-is, once with the backend change reverted and the wasm module rebuilt:exception_traceback_loop_formsis the only fixture the peel-prefix commit moves; the other 25 belong to the inputarg-types commit.Baseline re-record
_jit_stats_regression_floortreatsloops_abortedas a badness field, so it fails only on a rise — every reduction above passed while leaving the recorded ceiling untouched (exception_reraise_tb_depth_hotwas still free to go back from 0 to 44). The last commit re-records those 26 baselines. The one fixture whose number rose —exception_args_virtualloops_aborted 0 -> 3, a base-inherited red that reproduces on a build of this base with none of these commits — was deliberately restored from HEAD rather than recorded. On the current base it is back at its recorded value, so leaving it untouched is what keeps it green.Verification
cargo test -p majit-backend-wasm22/22, plus all three--ignoredruntime integration tests, includingglobal_reassign_retraces_non_last_label_backedge_at_runtime— an existing test for exactly the non-last-label shape, which runs it under wasmtime and compares stdout against dynasm.check.py --backend wasmon the current base (b44c30bae3b, jit: give W_UnicodeObject.len a parent SizeDescr; read the trailing -live- first for after-residual-call guards #989): 366/366 all passed — the 26 re-recorded baselines hold, andexception_args_virtualpasses here too, which is the base-inherited failure resolving on the newer base.check.py --backend dynasm,craneliftunchanged (this is a wasm-only backend change).The abort counts in the table were measured on the previous base (
c0891858087); an earlier three-backend gate was invalidated mid-run by a concurrent rebase onto #989, and the re-run above covers wasm on the new base. CI on this PR is the authority for the other two backends.🤖 Generated with Claude Code