Skip to content

JIT: Assertion PyType_Check((PyObject *)type) failed #149112

@devdanzin

Description

@devdanzin

Crash report

What happened?

It's possible to make a JIT build abort by running the code below.

MRE:

def f1():
    class C:
        def __getitem__(self, key): pass

    c = C()
    for _ in range(300):
        c[2]

for _ in range(300):
    f1()

Backtrace:

python: Python/optimizer_symbols.c:1277: void _Py_uop_sym_set_recorded_type(JitOptContext *, JitOptRef, PyTypeObject *): Assertion `PyType_Check((PyObject *)type)' failed.

Program received signal SIGABRT, Aborted.

#0  __pthread_kill_implementation (threadid=<optimized out>, signo=6, no_tid=0) at ./nptl/pthread_kill.c:44
#1  __pthread_kill_internal (threadid=<optimized out>, signo=6) at ./nptl/pthread_kill.c:89
#2  __GI___pthread_kill (threadid=<optimized out>, signo=signo@entry=6) at ./nptl/pthread_kill.c:100
#3  0x00007ffff7c45e2e in __GI_raise (sig=sig@entry=6) at ../sysdeps/posix/raise.c:26
#4  0x00007ffff7c28888 in __GI_abort () at ./stdlib/abort.c:77
#5  0x00007ffff7c287f0 in __assert_fail_base (fmt=<optimized out>, assertion=<optimized out>, file=<optimized out>, line=<optimized out>, function=<optimized out>) at ./assert/assert.c:118
#6  0x00007ffff7c3c19f in __assert_fail (assertion=<optimized out>, file=<optimized out>, line=<optimized out>, function=<optimized out>) at ./assert/assert.c:127
#7  0x000055555610a025 in _Py_uop_sym_set_recorded_type (ctx=ctx@entry=0x7bfff480b890, ref=ref@entry=..., type=type@entry=0x7d2ff6e37870) at Python/optimizer_symbols.c:1277
#8  0x00005555560ebe73 in optimize_uops (tstate=<optimized out>, trace=<optimized out>, trace_len=<optimized out>, curr_stacklen=<optimized out>, output=<optimized out>,
    dependencies=<optimized out>) at Python/optimizer_cases.c.h:5651
#9  0x00005555560e4728 in _Py_uop_analyze_and_optimize (tstate=tstate@entry=0x555556a754b8 <_PyRuntime+360760>, buffer=buffer@entry=0x7bfff483e2e0, length=6, length@entry=14,
    curr_stacklen=curr_stacklen@entry=4, output=0x16, output@entry=0x7bfff484cd40, dependencies=dependencies@entry=0x7bfff5a849a0) at Python/optimizer_analysis.c:796
#10 0x00005555560d4b0a in uop_optimize (frame=0x7e8ff6de5290, tstate=0x555556a754b8 <_PyRuntime+360760>, progress_needed=false, exec_ptr=<optimized out>) at Python/optimizer.c:1646
#11 _PyOptimizer_Optimize (frame=<optimized out>, tstate=<optimized out>) at Python/optimizer.c:166
#12 0x0000555555ef42cb in stop_tracing_and_jit (tstate=0x555556a754b8 <_PyRuntime+360760>, frame=frame@entry=0x7e8ff6de5290) at Python/ceval.c:1088
#13 0x0000555555eb724a in _PyEval_EvalFrameDefault (tstate=<optimized out>, frame=<optimized out>, throwflag=<optimized out>) at Python/generated_cases.c.h:13130
#14 0x0000555555e89af8 in _PyEval_EvalFrame (tstate=0x555556a754b8 <_PyRuntime+360760>, frame=0x7e8ff6de5220, throwflag=0) at ./Include/internal/pycore_ceval.h:118
#15 _PyEval_Vector (tstate=<optimized out>, func=<optimized out>, locals=<optimized out>, args=<optimized out>, argcount=<optimized out>, kwnames=0x0) at Python/ceval.c:2124
#16 0x0000555555e89515 in PyEval_EvalCode (co=<optimized out>, globals=<optimized out>, locals=0x7c7ff6e89440) at Python/ceval.c:686
#17 0x000055555617dad0 in run_eval_code_obj (tstate=tstate@entry=0x555556a754b8 <_PyRuntime+360760>, co=co@entry=0x7d1ff6e86cd0, globals=globals@entry=0x7c7ff6e89440,
    locals=locals@entry=0x7c7ff6e89440) at Python/pythonrun.c:1369
#18 0x000055555617cc9c in run_mod (mod=<optimized out>, filename=<optimized out>, globals=<optimized out>, locals=<optimized out>, flags=<optimized out>, arena=<optimized out>,
    interactive_src=<optimized out>, generate_new_source=<optimized out>) at Python/pythonrun.c:1472

Output of running with PYTHON_LLTRACE=4 PYTHON_OPT_DEBUG=4:
lltrace_opt_debug.txt

Claude's diagnosis and proposed fix

Diagnosis

The non-type pointer reaching sym_set_recorded_type originates as the operand0 of a _RECORD_TOS_TYPE uop in the just-finalized trace. That operand0 is set from tracer->prev_state.recorded_values[i] during translation. The values in those slots were populated by _PyJit_TryInitializeTracing (or by the TRACE_RECORD step) using the record-function schema for one opcode, but _PyJit_translate_single_bytecode_to_trace later interprets those same slots under a different opcode's schema, because translation has its own deopt-aware opcode rewrite that initialization does not mirror:

// Python/optimizer.c, inside _PyJit_translate_single_bytecode_to_trace
if (_PyOpcode_Caches[_PyOpcode_Deopt[opcode]] > 0) {
    uint16_t backoff = (this_instr + 1)->counter.value_and_backoff;
    if (backoff != adaptive_counter_cooldown().value_and_backoff &&
        backoff != trigger_backoff_counter().value_and_backoff) {
        opcode = _PyOpcode_Deopt[opcode];
    }
}

The MRE makes this visible with a single seam: each call to f1() defines a fresh class C, whose specialization cache for BINARY_OP_SUBSCR_GETITEM is empty on the first iteration. _BINARY_OP_SUBSCR_CHECK_FUNC therefore exits, and a side trace is requested at offset 36 — the same BINARY_OP instruction. At that moment:

  • the bytecode at offset 36 still reads BINARY_OP_SUBSCR_GETITEM (the specialization isn't physically rewritten); but
  • the adaptive counter is in neither cooldown nor trigger state, because tracing just deopted there.

Walking through both functions:

  1. _PyJit_TryInitializeTracing (Python/optimizer.c:1156) reads curr_instr->op.code directly:

    const _PyOpcodeRecordEntry *record_entry =
        &_PyOpcode_RecordEntries[curr_instr->op.code];

    That's BINARY_OP_SUBSCR_GETITEM, whose macro starts with _RECORD_NOS — a value recorder. So recorded_values[0] receives the NOS object itself: stack[-2], which is the C instance (Py_TYPE is never called).

  2. _PyJit_translate_single_bytecode_to_trace runs the deopt shim shown above. The counter check fires, so opcode flips to plain BINARY_OP, whose macro is _SPECIALIZE_BINARY_OP + _RECORD_TOS_TYPE + _RECORD_NOS_TYPE + unused/4 + _BINARY_OP + POP_TOP + POP_TOP. Translation now emits _RECORD_TOS_TYPE and _RECORD_NOS_TYPE and consumes recorded_values[0] and recorded_values[1] as their operand0s.

  3. The optimizer's abstract interpreter (case _RECORD_TOS_TYPE at Python/optimizer_cases.c.h:5651):

    PyTypeObject *tp = (PyTypeObject *)this_instr->operand0;
    sym_set_recorded_type(tos, tp);

    tp is the C instance. PyType_Check(tp) fails. Abort.

(recorded_values[1] is also wrong — it's whatever the slot held before, since the GETITEM schema only ever wrote slot 0 — but it never gets reached because slot 0 trips the assertion first.)

This isn't specific to subscript: any opcode whose specialized form has a different record schema from its deopted form is vulnerable, since the schemas drive both the set of recorded slots and what each slot is assumed to be.

Captured optimizer trace at the failing site (truncated to the relevant uops):

   0 abs: _START_EXECUTOR (target=36)
   ...
   3 abs: _SET_IP (target=36, operand0=<bytecode>)
   4 abs: _RECORD_TOS_TYPE (oparg=26, target=36, operand0=0x...ec30)  ← C instance
   ABORT

And the lltrace just before:

SIDE EXIT: ... target 36 -> BINARY_OP_SUBSCR_GETITEM
stack=[range_iter, NULL, <C inst @0x...ec30>, <int 2>]

Fix

Apply the same deopt-aware opcode rewrite inside _PyJit_TryInitializeTracing before computing record_entry, so initialization and translation agree on which schema is in force. Local change in Python/optimizer.c:

// Mirror the opcode-deopt decision in _PyJit_translate_single_bytecode_to_trace:
// if the cache backoff indicates a deopt during prior tracing, the translator
// will use the deopted opcode -- so we must record values for that opcode too,
// otherwise the recorded_values produced here are interpreted under a
// different uop-record schema (e.g. BINARY_OP_SUBSCR_GETITEM records 1 NOS
// value while BINARY_OP records 2 type slots), and the resulting trace gets
// a non-type pointer as the operand0 of _RECORD_TOS_TYPE.
int record_opcode = curr_instr->op.code;
if (_PyOpcode_Caches[_PyOpcode_Deopt[record_opcode]] > 0) {
    uint16_t backoff = (curr_instr + 1)->counter.value_and_backoff;
    if (backoff != adaptive_counter_cooldown().value_and_backoff &&
        backoff != trigger_backoff_counter().value_and_backoff) {
        record_opcode = _PyOpcode_Deopt[record_opcode];
    }
}
const _PyOpcodeRecordEntry *record_entry = &_PyOpcode_RecordEntries[record_opcode];

oparg doesn't need to change — it's the bytecode-level oparg, identical for the specialization and its deopted form (e.g. NB_SUBSCR = 26 for both BINARY_OP and BINARY_OP_SUBSCR_GETITEM). The translator independently fetches its own instr_oparg out of prev_state, which _PyJit_TryInitializeTracing set from the same source. Only the record schema was inconsistent, and that's exactly what this rewrite repairs.

Factoring the deopt check into a small shared helper used by both functions would also work and would be more robust against future drift; this snippet sticks to the minimum-surface change.

Verification

  • The MRE aborts on unmodified main and runs to completion (5/5) with the patch.
  • The same TRACE_RECORD path in Python/generated_cases.c.h:12506 already runs on the executed opcode (after the JIT-tracing instruction has been resolved), so it's not affected; the bug is specific to side-trace start, where init reads the bytecode-as-stored.

Found using lafleur.

CPython versions tested on:

CPython main branch, 3.15

Operating systems tested on:

Linux

Output from running 'python -VV' on the command line:

Python 3.15.0a8+ (heads/main-dirty:804c213c893, Apr 28 2026, 08:55:31) [Clang 21.1.2 (2ubuntu6)]

Metadata

Metadata

Assignees

No one assigned

    Labels

    interpreter-core(Objects, Python, Grammar, and Parser dirs)topic-JITtype-crashA hard crash of the interpreter, possibly with a core dump

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions