diff --git a/Lib/test/test_free_threading/test_func_annotations.py b/Lib/test/test_free_threading/test_func_annotations.py index 0fde0136d77c7f..d78e4905d19807 100644 --- a/Lib/test/test_free_threading/test_func_annotations.py +++ b/Lib/test/test_free_threading/test_func_annotations.py @@ -6,6 +6,11 @@ from test.support import threading_helper, Py_GIL_DISABLED +try: + import _testcapi +except ImportError: + _testcapi = None + threading_helper.requires_working_threading(module=True) @@ -19,6 +24,11 @@ def get_func_annotation_dunder(f, b): return f.__annotations__ +def get_func_annotation_capi(f, b): + b.wait() + return _testcapi.function_get_annotations(f) + + def set_func_annotation(f, b): b.wait() f.__annotations__ = {'x': int, 'y': int, 'return': int} @@ -65,3 +75,24 @@ def bar(x: int, y: float) -> float: # func_get_annotations returns in-place dict, so bar.__annotations__ should be modified as well self.assertEqual(bar.__annotations__, {'x': int, 'y': int, 'return': int}) + + @unittest.skipIf(_testcapi is None, "requires _testcapi") + def test_concurrent_capi_read_and_write(self): + # PyFunction_GetAnnotations() lazily computes and stores the + # annotations dict, so it must hold a critical section on the + # function object, like the __annotations__ getter and setter do. + for _ in range(10): + def foo(x: int) -> int: + return x + 1 + + with concurrent.futures.ThreadPoolExecutor(max_workers=self.NUM_THREADS) as executor: + b = Barrier(self.NUM_THREADS) + futures = {} + for i in range(self.NUM_THREADS): + func = get_func_annotation_capi if i % 2 else set_func_annotation + futures[executor.submit(func, foo, b)] = i + for fut in concurrent.futures.as_completed(futures): + annotations = fut.result() + # foo always has annotations, either computed from + # foo.__annotate__ or set by set_func_annotation() + self.assertIsInstance(annotations, dict) diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-09-17-54-30.gh-issue-159091.Kx7Qm2.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-09-17-54-30.gh-issue-159091.Kx7Qm2.rst new file mode 100644 index 00000000000000..2ab39194d4ad5e --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-09-17-54-30.gh-issue-159091.Kx7Qm2.rst @@ -0,0 +1 @@ +Fix a data race between :c:func:`PyFunction_GetAnnotations` and assigning to ``__annotations__`` in the free-threaded build. diff --git a/Objects/funcobject.c b/Objects/funcobject.c index 4d3da234f87e49..20c7e2aeac9ad2 100644 --- a/Objects/funcobject.c +++ b/Objects/funcobject.c @@ -1,6 +1,7 @@ /* Function object implementation */ #include "Python.h" +#include "pycore_critical_section.h" // Py_BEGIN_CRITICAL_SECTION() #include "pycore_code.h" // _PyCode_VerifyStateless() #include "pycore_dict.h" // _Py_INCREF_DICT() #include "pycore_function.h" // _PyFunction_Vectorcall @@ -589,7 +590,11 @@ PyFunction_GetAnnotations(PyObject *op) PyErr_BadInternalCall(); return NULL; } - return func_get_annotation_dict((PyFunctionObject *)op); + PyObject *annotations; + Py_BEGIN_CRITICAL_SECTION(op); + annotations = func_get_annotation_dict((PyFunctionObject *)op); + Py_END_CRITICAL_SECTION(); + return annotations; } int