Generated `__annotate__` uses `COMPARE_OP` without the bool-conversion flag, feeding a non-bool to `POP_JUMP_IF_FALSE`
Nobody has claimed this yet.
- 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_FALSEtreats the non-Py_Falsevalue as truthy →__annotate__spuriously raisesNotImplementedError; 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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