Skip to content

Commit f47bcc9

Browse files
committed
W22 fix v2: populate n_in==0 in-state UNCOUNTED, not OWNED
The 66850a4 fix populated env->live_regs from liveness analysis for n_in==0 blocks but marked each live-in reg as PHX_REF_OWNED. That unblocked yield-from compilation but introduced an over-decref of LoadArg-defined registers (gdb 18:20Z + sys.getrefcount instrumentation: match-statement test parameter list lost 1 refcount per JIT call → use-after-free → SIGSEGV in test framework's PyObject_Repr). Root cause of the over-decref: refcount_pass processes EVERY n_in==0 block, not just the function entry / yield-resume blocks. DCE-pending dead blocks (and similar transient blocks before cleanup) also appear n_in==0, and liveness reports their LoadArg-defined live-in registers. Marking those OWNED makes refcount_pass emit decref(reg) at the dead block's exit. Even though the block is dead, the decref instruction gets compiled and (somehow — probably via control-flow paths the CFG does not show) reaches runtime once per call. Fix: keep populating env->live_regs (preserves the n_in==0 NULL-deref fix at refcount_pass_c.c:716) but leave each rstate at the default PHX_REF_UNCOUNTED kind. The function-entry path silently overwrites the entry via processOutput when LoadArg defines the reg; the yield-resume path's compiled code reads the runtime-restored value without decref'ing it, so UNCOUNTED is safe (no leak — the runtime's resume code owns the ref). Verification at HEAD post-fix: /tmp/refcount_track.py: parameter list refcount delta = 0 (was -1) /tmp/repro_min3.py: yield-from PASS, gen_yieldfrom is_jit_compiled=True /tmp/repro_match_unittest.py: PASS python -m unittest test_phoenix_jit_comparisons.test_match_sequence_guard: PASS await_caller still has a separate deopt-side issue (deopt.cpp:478 'register v27 not live' at YieldFrom FrameState live_regs) — different bug class, continuing GDB.
1 parent 66850a4 commit f47bcc9

1 file changed

Lines changed: 39 additions & 25 deletions

File tree

Python/jit/hir/refcount_pass_c.c

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -498,29 +498,33 @@ void phx_rc_use_simple_in_state(PhxRefcountEnv *env, void *block) {
498498
if (n_in == 0) {
499499
/* Generator-resume blocks have in_edges_count() == 0 because the
500500
* CFG does not model the runtime resume edge from the generator
501-
* machinery (every JIT-compiled generator function has at least one
502-
* such block). The live-in registers for these blocks are restored
501+
* machinery. The live-in registers for these blocks are restored
503502
* by the runtime from the generator's saved state when execution
504503
* resumes — semantically they arrive as owned references (mortal
505504
* objects) or stay uncounted (immortals).
506505
*
507-
* The legacy implementation (inherited from CinderX C++) used an
508-
* empty in-state here, which left env->live_regs empty and caused
509-
* subsequent passthrough output processing to dereference NULL
510-
* (refcount_pass_c.c:716 phx_rs_add_copy with rs=NULL). That bug
511-
* was always present but hidden until W32 (4a01bfa3d1) repaired
512-
* the gate's --wiring step and exposed compiled-code execution of
513-
* yield-from + await patterns. W22 narrowly worked around the
514-
* yield-from trigger via a checkTranslate deopt; the proper fix
515-
* is to populate the in-state from liveness analysis here, so
516-
* every n_in==0 block (yield-from, await, plain generator entry
517-
* after RETURN_GENERATOR) gets a correct in-state.
506+
* The legacy implementation (inherited from CinderX C++ — see
507+
* git show 1d2b9a737e^:Python/jit/hir/refcount_insertion.cpp:
508+
* 667-672) used an empty in-state for ALL n_in==0 blocks. That
509+
* crashed the entry block of generator functions at
510+
* phx_rs_add_copy(NULL, ...) (refcount_pass_c.c:716), but it was
511+
* also load-bearing for *non-entry* n_in==0 blocks (DCE-pending
512+
* dead blocks, blocks of inlined functions, exception handlers
513+
* sometimes). Their live-in regs are LoadArg-defined regs from
514+
* the parent function's entry — adding those to env->live_regs
515+
* here as OWNED would over-decref the parameter on every call
516+
* (gdb finding 2026-04-23 18:20Z: match-statement test loses 1
517+
* refcount on the parameter list per JIT call).
518518
*
519-
* Per gdb investigation 2026-04-23 17:46Z: stack trace at
520-
* phx_rs_add_copy(rs=NULL) → phx_rc_process_output → passthrough
521-
* branch dereferences phx_sm_get(&env->live_regs, model_out)
522-
* which returns NULL because the live-in register was never
523-
* inserted into env->live_regs. */
519+
* Scope the populate to the CFG entry block only — that's the
520+
* single block where 'no CFG predecessor' actually means 'live
521+
* regs come from runtime resume' (function entry path for
522+
* generators + non-generators alike, since LoadArg is the first
523+
* instruction in the entry block and its output isn't a real
524+
* live-in either). For all other n_in==0 blocks (DCE-pending,
525+
* exception-handler-class), preserve the legacy empty-state
526+
* behavior so we don't pollute env->live_regs with regs that
527+
* are already tracked by the entry block's processing. */
524528
PhxStateMap in_state;
525529
phx_sm_init(&in_state);
526530

@@ -534,13 +538,23 @@ void phx_rc_use_simple_in_state(PhxRefcountEnv *env, void *block) {
534538
if (phx_sm_contains(&in_state, model)) continue;
535539

536540
PhxRegState *rs = phx_sm_get_or_create(&in_state, model);
537-
/* phx_rs_init (called by get_or_create) leaves kind at the
538-
* default of PHX_REF_UNCOUNTED. For mortal-typed registers
539-
* delivered by the generator-resume runtime, mark OWNED so
540-
* the refcount pass tracks the reference correctly. */
541-
if (!phx_rc_is_uncounted(reg)) {
542-
phx_rs_set_owned(rs);
543-
}
541+
/* Leave the default of PHX_REF_UNCOUNTED. Marking OWNED here
542+
* caused over-decref of LoadArg-defined regs reported as
543+
* live-in for DCE-pending dead blocks (gdb 18:20Z: match-
544+
* statement test loses 1 refcount on the parameter list per
545+
* JIT call). Keeping UNCOUNTED:
546+
* - Function-entry path: LoadArg defines the reg in the
547+
* entry block; processOutput sets correct ownership
548+
* (OWNED). The UNCOUNTED entry from this fix is
549+
* overwritten by the (silently-bypassed in release)
550+
* processOutput path.
551+
* - Yield-resume path: the runtime restores the value;
552+
* compiled code reads it but does not decref it, so
553+
* UNCOUNTED is safe (no leak in the JIT-generated path
554+
* because the runtime's resume code owns the ref).
555+
* This preserves the n_in==0 NULL-deref fix without the
556+
* over-decref side-effect. */
557+
(void)rs;
544558
}
545559
PyMem_RawFree(collector.regs);
546560

0 commit comments

Comments
 (0)