Repository navigation
Function __annotations__ can cache a stale result from __annotate__ #159434
Copy link
Copy link
Open
Labels
interpreter-core(Objects, Python, Grammar, and Parser dirs)(Objects, Python, Grammar, and Parser dirs)type-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error
Description
Bug report
When f.__annotations__ is read and not yet cached, func_get_annotation_dict() (Objects/funcobject.c) calls f.__annotate__(1) and stores the result in func_annotations unconditionally. __annotate__ is arbitrary code, so it can replace __annotate__ or __annotations__ while it runs. The value it set is then overwritten by the stale result computed from the old callback.
This can happen in two ways:
- One thread: the callback assigns
f.__annotations__orf.__annotate__itself. - Free-threaded build: the
__annotations__getter holds the function's critical section while calling__annotate__. The call can suspend the critical section (blocking, or taking another critical section), and another thread can then set__annotations__or__annotate__.
Reproducer:
def f(): pass
def annotate(format):
f.__annotations__ = {"x": int}
return {"stale": str}
f.__annotate__ = annotate
print(f.__annotations__) # {'stale': <class 'str'>}, expected {'x': <class 'int'>}
print(f.__annotate__) # None After this runs, __annotate__ is None (cleared by the __annotations__ assignment), but the cached annotations come from the callback that was replaced.
There are two related issues in the same function:
- The callable is called through a borrowed reference (
op->func_annotate). If the field is replaced during the call, the last reference to the running callable can be dropped. Python functions and bound methods keep themselves alive while running, so this is unlikely to crash, but it isn't guaranteed for all callables. - Converting the compiler-generated annotations tuple to a dict uses a borrowed reference to the tuple and stores the dict unconditionally. On the free-threaded build the critical section can be suspended during
PyDict_New()/PyDict_SetItem()(e.g. by a GC finalizer), so the tuple can be replaced or freed while it is being read.
This bug was analyzed with assistance from Claude Opus 5.5.
CPython versions tested on:
CPython main branch
Operating systems tested on:
Linux
Linked PRs
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
interpreter-core(Objects, Python, Grammar, and Parser dirs)(Objects, Python, Grammar, and Parser dirs)type-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error
Activity
JelleZijlstra commentedon Oct 14, 2026
I believe I thought about this while writing the code and decided it wasn't worth the effort to do something more complicated, since as far as I can tell it only affects pathological scenarios.
We should fix the borrowed-reference bugs in your related issues list, though.