diff --git a/Lib/test/test_free_threading/test_dict.py b/Lib/test/test_free_threading/test_dict.py index 4a812275143bc4d..9f8c982d055d994 100644 --- a/Lib/test/test_free_threading/test_dict.py +++ b/Lib/test/test_free_threading/test_dict.py @@ -356,5 +356,61 @@ def writer(): threading_helper.run_concurrently([reader, writer]) + def test_racing_dict_update_and_method_lookup_with_inline_values(self): + # gh-149816: sub-case 108 + # The race below checks that a detached dict is still valid + # when racing setattr with __dict__ replacement + class Target: + pass + + def appender(obj: Target, start: Barrier, iter_times: int) -> None: + start.wait() + index = 0 + for _ in range(iter_times): + setattr(obj, f"probe_{index}", index) + index += 1 + time.sleep(0) + + def replacer(obj: Target, start: Barrier, iter_times: int, churn_size: int) -> None: + start.wait() + for _ in range(iter_times): + old_dict = obj.__dict__ + obj.__dict__ = {} + del old_dict + time.sleep(0) + # create a list of dicts to trigger a realloc of the dict's table + # and ensure that the old dict is not used after it is deleted + realloc_trigger_list = [{"k": j} for j in range(churn_size)] + del realloc_trigger_list + + def race(iter_times: int, + churn_size: int, + appender_threads: int, + replacer_threads: int) -> None: + obj = Target() + setattr(obj, "origin", 0) + _ = obj.__dict__ # Access __dict__ to ensure it's initialized + + start = Barrier(appender_threads + replacer_threads) + threads = [] + for _ in range(appender_threads): + threads.append(Thread(target=appender, args=(obj, start, iter_times), + name="appender")) + for _ in range(replacer_threads): + threads.append(Thread(target=replacer, args=(obj, start, iter_times, churn_size), + name="replacer")) + + with threading_helper.catch_threading_exception() as cm: + with threading_helper.start_threads(threads): + pass + if cm.exc_type is not None: + raise cm.exc_value + + ITER_TIMES = 2_000 + APPENDER_THREADS = 8 + REPLACER_THREADS = 8 + CHURN_SIZE = 512 + race(ITER_TIMES, CHURN_SIZE, APPENDER_THREADS, REPLACER_THREADS) + if __name__ == "__main__": unittest.main() diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-06-26-00-02-29.gh-issue-149816.JKN-5B.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-26-00-02-29.gh-issue-149816.JKN-5B.rst new file mode 100644 index 000000000000000..255507548703b12 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-26-00-02-29.gh-issue-149816.JKN-5B.rst @@ -0,0 +1,3 @@ +Fix a race in free-threaded builds where storing an instance attribute could +use a stale borrowed reference to an instance ``__dict__`` after another thread +replaced it. diff --git a/Objects/dictobject.c b/Objects/dictobject.c index 74b6d5d779a064c..8ca85d674263c0b 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -7556,17 +7556,25 @@ _PyObject_StoreInstanceAttribute(PyObject *obj, PyObject *name, PyObject *value) { PyDictValues *values = _PyObject_InlineValues(obj); if (!FT_ATOMIC_LOAD_UINT8(values->valid)) { - PyDictObject *dict = _PyObject_GetManagedDict(obj); + PyDictObject *dict; + int res; +#ifdef Py_GIL_DISABLED + Py_BEGIN_CRITICAL_SECTION(obj); +#endif + dict = _PyObject_GetManagedDict(obj); + Py_XINCREF(dict); +#ifdef Py_GIL_DISABLED + Py_END_CRITICAL_SECTION(); +#endif if (dict == NULL) { dict = (PyDictObject *)PyObject_GenericGetDict(obj, NULL); if (dict == NULL) { return -1; } - int res = store_instance_attr_dict(obj, dict, name, value); - Py_DECREF(dict); - return res; } - return store_instance_attr_dict(obj, dict, name, value); + res = store_instance_attr_dict(obj, dict, name, value); + Py_DECREF(dict); + return res; } #ifdef Py_GIL_DISABLED