Skip to content
56 changes: 56 additions & 0 deletions Lib/test/test_free_threading/test_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 13 additions & 5 deletions Objects/dictobject.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading