Skip to content

A JIT resume restores last_instr without its partner valuestackdepth, so a function that always returns an int returns None #1444

Description

@youknowone

Edited after filing. The first version of this issue asserted that the frame
resumed inside the Cache word at pc 67. That was an inference from two numbers,
not an observation, and it is wrong — see Reading the numbers below. The
reproducer and everything else stand.

A function whose every path returns an int returns None under the JIT, and the
caller reports it as a type error:

TypeError: unsupported operand type(s) for +: 'int' and 'NoneType'

That is the shape most likely to be met in the wild, and it reads as a Python-level
mistake rather than a JIT defect.

Still open. Measured on main at 9cd072d663d and again at 9be6297e7dd (after
#1414, #1422 and #1425 landed — none of them touch it), and re-run today on a tree
based on main at 9a900eef62b, where it still reproduces. Backend-independent:
dynasm and cranelift fail identically. Passes under PYRE_NO_JIT=1, and survives
PYRE_TRACE_EAGERNESS at both 1 and 1000000, PYRE_WALKABORT_OFF=1 and
PYRE_FORITER_CALL_BODY=1.

Reproducing

⚠️ Both loops must be in one process. Neither alone reproduces at any size, and a
single-loop probe at 1200 trials is clean on every binary — that is what makes this
easy to dismiss as non-reproducible.

Thresholds (measured):

trips does not trip
comprehension loop 50 trials, n = trial % 50 40 trials; or % 40
statement loop after it 60 trials, n = trial % 20 40 trials; or % 2
def ck(seq):
    h = 7
    for v in seq:
        h = (h * 1000003 + v) & 0xFFFFFFFFFF
    return h

def rec(x):
    if x <= 0:
        return 1 if id(x) else 0
    return rec(x - 1) + 1

class W:
    def __init__(self):
        self.seen = []
    def step(self, x):
        self.seen.append(x)
        return x * 1000003 + rec(x % 4)

hh = 7
for trial in range(50):                 # comprehension loop
    n = trial % 50
    w = W()
    res = [w.step(i) for i in range(n)]
    hh = (hh * 1000003 + ck(res) + ck(w.seen)) & 0xFFFFFFFFFF
for trial in range(60):                 # statement loop
    n = trial % 20
    w = W()
    res = []
    for i in range(n):
        res.append(w.step(i))
    hh = (hh * 1000003 + ck(res) + ck(w.seen)) & 0xFFFFFFFFFF
print(hh)

CPython 3.14 prints 592304421450; pyre raises the TypeError above.

Grade this by the plain script. Adding is None checks changes what compiles:
the instrumented variant aborts on some binaries and passes on others, so it is not a
stable oracle.

The harder surface

With the is None checks the wrong value becomes a hard, non-unwinding panic (it
aborts rather than raising):

thread '<unnamed>' panicked at pyre/pyre-interpreter/src/pyframe.rs:
value-stack underflow: depth=2 base=2 (nlocals=2 ncells=0) pc=67 last_instr=66
  ninstrs=100 code="rec"
window=[ ... 64:StoreFast{1} 65:LoadFastBorrow{1} 66:PopJumpIfNotNone{delta:23} 67:Cache 68:NotTaken ... ]
... pyre_jit::call_jit::ll_portal_runner_shim

A separate sweep found a third surface in the same family — rec() self-recursive
and carrying a for loop fails with TypeError: 'int' object is not an iterator at
rec's own for header, 32 of 128 shape variants, all 32 in that family.

Reading the numbers

pc in report_stack_underflow is next_instr(), so last_instr=66 pc=67 names
resume_py_pc 66 — an ordinary opcode boundary, not the Cache word at 67. What
the report actually says is that the frame's two resume coordinates disagree: the depth
is 2, equal to base, i.e. the operand stack is empty, while last_instr claims the
frame is at 66 (PopJumpIfNotNone), whose operand at 65 (LoadFastBorrow) pushes.

That pair is already written down in the tree. capture_frame_scalars
(pyre/pyre-jit-trace/src/state.rs) states the invariant verbatim — "the interpreter
derives its next opcode from last_instr + 1, and reads the operand stack at
valuestackdepth. Restoring only the locals leaves the two disagreeing"
— and
FrameScalars { last_instr, valuestackdepth } exists as the pair type. The invariant
is enforced nowhere.

Where the inconsistency is written

Measured writer: maybe_publish_inline_callee_last_instr_concrete
(pyre-jit-trace/src/jitcode_dispatch/residual_call.rs, via
record_and_publish_inline_callee_last_instr). It writes last_instr = callee_py_pc
onto the inline callee's concrete frame and writes no valuestackdepth beside it.
Its own comment claims exemption as "the authentic per-opcode PyFrame transition",
but nothing rolls it back and nothing supplies the partner depth.
LiveLastInstrGuard::enter_frame then captures an already-inconsistent pair and
restores it verbatim on drop. An env-gated audit comparing (last_instr+1, valuestackdepth) against the forward analysis catches it once per run:

[resume-audit] inline_callee_last_instr: next_instr=18 op=ForIter
  analysis_depth=Some(1) frame_depth=Some(0) (vsd=3 base=3)

Three other candidates were refuted by measurement, each after looking like a real
asymmetry in the source:

candidate status
adopt_blackhole_crn — fixes last_instr alone where its sibling apply_blackhole_crn_handoff fixes both via correct_resume_vsd real, but never reached on the repro (audit placed inside it never fired)
LiveLastInstrGuard (residual_call.rs) — saves last_instr alone refuted: made it save/restore the whole FrameScalars pair and the audit still fires; the guard is faithful
the CALL_ASSEMBLER loop-callee pin in emit_walker_loop_callee_call_assembler (pins last_instr = target_pc - 1 on a frame seeded with an empty operand stack) real and live, 11 emits in str_search_index_bounds.py, but zero events on this repro

So a point fix on any one writer does not close the class. last_instr carries two
meanings on one field — the executing pc during a walk, and resume_pc − 1 outside it
— while the concrete frame is simultaneously the walker's live interpreter state and
the resume image. LiveLastInstrGuard exists because those collide, but it brackets
only residual calls; the inline-callee publish writes an executing coordinate outside
any bracket.

Upstream offers no template: emit_new_pyframe_inline_with_params is pyre-only, and
neither direct_assembler_call nor ll_portal_runner constructs a callee frame inside
the trace. Any fix is a pyre-side invariant, not a port.

⛔ A related hole, named but not closed: depth_based_vsd_for_wcode returns None for
an out-of-range pc, and both correct_resume_vsd and the adopt_blackhole_crn arm
silently leave the depth alone in that case. For an unreachable pc, depth_at_py_pc()
collapses its usize::MAX sentinel to 0 by documented design (liveness.rs), so an
unreachable pc reads depth 0 rather than declining. Not exercised by this reproducer.

reported by Claude

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions