https://github.com/python/cpython/commit/60e956e18b6d23de777f964010b11adddf147750
commit: 60e956e18b6d23de777f964010b11adddf147750
branch: main
author: Edward Xu <[email protected]>
committer: DinoV <[email protected]>
date: 2026-10-22T10:44:20-07:00
summary:

gh-149816: Fix race between `setattr` and `object.__dict__` update (#152296)

Fix race between `setattr` and `object.__dict__` update (#152296)

files:
A 
Misc/NEWS.d/next/Core_and_Builtins/2026-06-26-00-02-29.gh-issue-149816.JKN-5B.rst
M Lib/test/test_free_threading/test_dict.py
M Objects/dictobject.c

diff --git a/Lib/test/test_free_threading/test_dict.py 
b/Lib/test/test_free_threading/test_dict.py
index 2180613f689c89..12cea4b98a1724 100644
--- a/Lib/test/test_free_threading/test_dict.py
+++ b/Lib/test/test_free_threading/test_dict.py
@@ -437,5 +437,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 00000000000000..255507548703b1
--- /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 41b90f9277d0f2..e7ec0fd7c0e56e 100644
--- a/Objects/dictobject.c
+++ b/Objects/dictobject.c
@@ -7572,17 +7572,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

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to