Two lines of Python that segfault the interpreter | Timofei Ivankov
Here is a complete Python program that, until last week, crashed every CPython that<br>has annotations lazily evaluated — 3.14, 3.15, and 3.16:
__conditional_annotations__ = 0<br>a: 1
No C extension, no ctypes, no threads. Two lines of plain Python, and the process<br>dies with a signal instead of a traceback:
$ python3.14 crash.py<br>$ echo $?<br>138 # 128 + 10, SIGBUS on this macOS build
The reporter saw a plain segfault. I get a bus error. Which signal you land on depends<br>on what happens to be sitting in memory next to the object you just corrupted — the<br>interesting part is that there is no traceback at all.
a: 1 is a nonsense annotation, but that is not the problem — annotations are not<br>evaluated eagerly anymore. The problem is the first line, which reassigns a name<br>most people have never heard of.
The set nobody told you about
Python 3.14 added PEP 649 and<br>PEP 749: annotations are no longer evaluated at<br>definition time; they are moved into a lazily-called __annotate__ function.
That created a bookkeeping problem. Libraries write this all the time:
if TYPE_CHECKING:<br>someattr: SpecialType
__annotate__ runs later, long after the if has been decided, so it has no idea<br>whether that branch was taken. PEP 749 solves it with a side channel: every annotated<br>assignment at module level — and every conditional one in a class body — gets a unique<br>integer, and the enclosing body maintains a set of the ones it actually executed.
You can see the whole mechanism in the disassembly of a two-annotation module:
if True:<br>a: int<br>b: str
$ python3.14 -m dis mod.py<br>1 ...<br>BUILD_SET 0<br>STORE_NAME 0 (__conditional_annotations__)
2 LOAD_NAME 0 (__conditional_annotations__)<br>LOAD_SMALL_INT 0<br>SET_ADD 1<br>POP_TOP
3 LOAD_NAME 0 (__conditional_annotations__)<br>LOAD_SMALL_INT 1<br>SET_ADD 1<br>POP_TOP
and on the other side, inside __annotate__:
2 LOAD_SMALL_INT 0<br>LOAD_GLOBAL 0 (__conditional_annotations__)<br>CONTAINS_OP 0 (in)<br>POP_JUMP_IF_FALSE 10 (to L2)
Note the opcodes: STORE_NAME and LOAD_NAME. This is not a hidden stack slot — it is an ordinary module global,<br>sitting in globals() next to everything else you wrote, under a name anyone can assign to. That is the whole bug.
Why SET_ADD never checked
Before 3.14, every SET_ADD the compiler emitted — in set comprehensions and in set<br>displays alike — took its operand straight from a BUILD_SET a few instructions<br>earlier. The set sits on the stack and never gets a name, so no Python code can reach<br>it, let alone rebind it. The compiler knows the operand is a set because the compiler<br>is what put it there. So the opcode skips the check.
inst(SET_ADD, (set, unused[oparg-1], v -- set, unused[oparg-1])) {<br>int err = _PySet_AddTakeRef((PySetObject *)PyStackRef_AsPyObjectBorrow(set),<br>PyStackRef_AsPyObjectSteal(v));<br>ERROR_IF(err);
That cast to PySetObject * is unchecked, and _PySet_AddTakeRef goes straight for<br>the internals — so->mask, so->table, so->used. Those fields sit well past the end<br>of a small int, so the interpreter reads garbage as a hash table pointer and<br>dereferences it. Classic type confusion.
PEP 749 gave the opcode a second caller, and that one loads its operand by name.<br>Nobody removed a check; the check was never needed until the ground moved.
The issue was reported by Lydxn on<br>July 30. I picked it up and ended up writing two different fixes for it, which turned<br>out to be the actually interesting part.
The fix that couldn’t be backported
On main the right answer is not to make a generic opcode defensive — it is to stop<br>using a generic opcode for a specific job. Conditional annotations got their own<br>intrinsic:
static PyObject *<br>add_conditional_annotation(PyThreadState* tstate, PyObject *conditional_annotations,<br>PyObject *index)<br>if (!PySet_CheckExact(conditional_annotations)) {<br>_PyErr_Format(tstate, PyExc_TypeError,<br>"__conditional_annotations__ must be a set, not %T",<br>conditional_annotations);<br>return NULL;<br>if (PySet_Add(conditional_annotations, index) 0) {<br>return NULL;<br>Py_RETURN_NONE;
and the code generator stopped emitting SET_ADD for annotations entirely:
ADDOP_I(c, loc, CALL_INTRINSIC_2, INTRINSIC_ADD_CONDITIONAL_ANNOTATION);
Set comprehensions and displays keep their fast, unchecked opcode. Annotations get an operation<br>that owns its own invariant and can name the variable in the error message, because<br>that intrinsic exists for nothing else.
The catch: this adds an intrinsic, changes emitted bytecode, and bumps the pyc magic<br>number from 3704 to 3705. That is a non-starter on a released branch —<br>#155026 could only go to main.
The fix for 3.14 and 3.15
3.14 and 3.15 are already out. No new opcodes, no new intrinsics, no magic number<br>bump. So those branches get the boring fix instead: teach SET_ADD to check.
PyObject *set_o = PyStackRef_AsPyObjectBorrow(set);<br>// gh-154902: user code can rebind __conditional_annotations__<br>if (!PySet_CheckExact(set_o)) {<br>_PyErr_Format(tstate, PyExc_TypeError,<br>"'%T' object is not...