python / python/cpython

Generated `__annotate__` uses `COMPARE_OP` without the bool-conversion flag, feeding a non-bool to `POP_JUMP_IF_FALSE`

Open
#153,354 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

interpreter-core topic-typing type-crash
Dominant language
Python
Stars
77.2k
Forks
35.9k
PR merge metrics
PR metrics pending

Description

Crash report

What happened?

The compiler-generated __annotate__ function (PEP 649) begins with a guard if .format > VALUE_WITH_FAKE_GLOBALS: raise NotImplementedError. That comparison is emitted as a COMPARE_OP without the "convert result to bool" flag, immediately followed by POP_JUMP_IF_FALSE — which requires a boolean operand. When __annotate__ is called with a format whose > comparison returns a non-bool, the raw value reaches the jump:

python: Python/generated_cases.c.h: _PyEval_EvalFrameDefault:
    Assertion `PyStackRef_BoolCheck(cond)' failed.

Minimal reproducer (aborts on a debug build):

class NonBool:
    def __gt__(self, other):
        return None            # any non-bool result

def f(a: int):                 # any annotated function/class/module has __annotate__
    ...

f.__annotate__(NonBool())

It reproduces via a module __annotate__, a function __annotate__, and a class __annotate_func__ alike (the codegen is shared). Original find was wsgiref.types.__annotate__(obj) where obj.__gt__ returns a non-bool.

Root cause

Python/codegen.c, codegen_setup_annotations_scope():

// if .format > VALUE_WITH_FAKE_GLOBALS: raise NotImplementedError
...
ADDOP_I(c, loc, COMPARE_OP, (Py_GT << 5) | compare_masks[Py_GT]);   // no bool-cast bit (0x10)
NEW_JUMP_TARGET_LABEL(c, body);
ADDOP_JUMP(c, loc, POP_JUMP_IF_FALSE, body);

The COMPARE_OP oparg omits bit 0x10, which the eval loop checks to convert the comparison result to a bool (if (oparg & 16) in _COMPARE_OP, Python/bytecodes.c). POP_JUMP_IF_FALSE asserts its operand is a bool. Normal comparisons emit COMPARE_OP without the bit and rely on the later TO_BOOL-folding optimizer pass to set it — see the comment in codegen_addcompare():

// ... The fifth-lowest bit indicates whether the result should be converted to
// bool and is set later):
ADDOP_I(c, loc, COMPARE_OP, (cmp << 5) | compare_masks[cmp]);

But this guard emits COMPARE_OP directly followed by POP_JUMP_IF_FALSE with no intervening TO_BOOL, so the optimizer has nothing to fold and the bit is never set.

Disassembling the same > comparison two ways makes the difference concrete:

if x > 2:                     ->  COMPARE_OP  148  (bool(>))     # bit 0x10 SET
<generated __annotate__>      ->  COMPARE_OP  132  (>)           # bit 0x10 CLEAR

With a normal integer format (annotationlib.Format.VALUE, ...), int > int returns a real bool, so the assertion holds and the bug is latent. A format whose rich comparison returns a non-bool sends the raw object to the jump.

Behavior by build
  • Debug build: assert(PyStackRef_BoolCheck(cond)) fails → abort.
  • Release build: no assert, but POP_JUMP_IF_FALSE treats the non-Py_False value as truthy → __annotate__ spuriously raises NotImplementedError; and it consumes the operand without a decref (the jump assumes an immortal bool), leaking a heap comparison result.

More generally, the compiler is supposed to guarantee that every POP_JUMP_IF_FALSE/POP_JUMP_IF_TRUE operand is a bool; this codegen path breaks that guarantee for arbitrary format arguments.

Proposed fix

Set the bool-conversion bit on the guard's COMPARE_OP, matching what a normal if a > b: ends up with:

-    ADDOP_I(c, loc, COMPARE_OP, (Py_GT << 5) | compare_masks[Py_GT]);
+    ADDOP_I(c, loc, COMPARE_OP, (Py_GT << 5) | compare_masks[Py_GT] | 16);

(Py_GT << 5) | compare_masks[Py_GT] | 16 == 148, identical to the oparg the optimizer produces for if a > b:. (Emitting a TO_BOOL after the COMPARE_OP would also work; maintainers may prefer a named constant over the literal 16.)


Found by the fusil fuzzer (@vstinner's fuzzer, revived by @devdanzin);
investigated and drafted by Claude Code (Opus 4.8).

CPython versions tested on:

CPython main branch, 3.16, 3.15, 3.14

Operating systems tested on:

Linux

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

Python 3.16.0a0 (heads/main:bcf98ddbc40, Jul 4 2026, 15:04:52) [Clang 21.1.8 (6ubuntu1)]

Linked PRs
  • gh-153360

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Read codegen_setup_annotations_scope in Python/codegen.c and the COMPARE_OP handling in Python/bytecodes.c. Reproduce the NonBool example on a debug build, then add regression coverage for the function, module, and class annotation helpers. Done when the reproducer no longer aborts or raises spuriously.

Written by the indexing model from the issue text.

Assessment

Tech stack
c, python
Domain
compilers
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Stale
Clarity
Clearly specified
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.