Skip to content

Function __annotations__ can cache a stale result from __annotate__ #159434

Open

Description

@nascheme

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__ or f.__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

Activity

  1. JelleZijlstra commented on Oct 14, 2026

    @JelleZijlstra
    Member

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    interpreter-core(Objects, Python, Grammar, and Parser dirs)type-bugAn unexpected behavior, bug, or error

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions