diff --git a/Lib/test/test_thread.py b/Lib/test/test_thread.py index ac924728febc99..1dc15efb240c15 100644 --- a/Lib/test/test_thread.py +++ b/Lib/test/test_thread.py @@ -345,6 +345,53 @@ def func(): handle = thread.start_joinable_thread(func, handle=None) handle.join() +class StartNewThreadKwargsRace(unittest.TestCase): + + def setUp(self): + key = threading_helper.threading_setup() + self.addCleanup(threading_helper.threading_cleanup, *key) + + @unittest.skipUnless(support.Py_GIL_DISABLED, "GIL must be disabled") + def test_dict_growsup_when_thread_start(self): + # See gh-149816 - (62) Concurrent kwargs growth causes heap overwrite + # This test is meant to be run under a free-threaded build, where the GIL is + # disabled and concurrent mutations of the same dict can cause heap + # corruption. + results = [] + def mutator(shared, stop, prefix, burst): + i = 0 + while not stop.locked(): + for _ in range(burst): + shared[f"{prefix}_{i}"] = i + i += 1 + time.sleep(0) + results.append(prefix) + + def nop(i, **kwargs): + results.append(i) + + with threading_helper.wait_threads_exit(): + stop = thread.lock() + shared = {f"base_{i}": i for i in range(20000)} + n = 4 + for i in range(n): + args=(shared, stop, f"dynamic_{i}", 1000) + thread.start_new_thread(mutator, args) + + snt = 16 + for i in range(snt): + try: + thread.start_new_thread(nop, (i,), shared) + except RuntimeError: + break + + stop.acquire() + # wait for all mutator/nop threads stop. + for _ in support.sleeping_retry(support.SHORT_TIMEOUT): + if len(results) == n+snt: + break + self.assertTrue(True, "successful test") + class Barrier: def __init__(self, num_threads): diff --git a/Misc/NEWS.d/next/Library/2026-05-20-19-10-09.gh-issue-149816.g_ycIN.rst b/Misc/NEWS.d/next/Library/2026-05-20-19-10-09.gh-issue-149816.g_ycIN.rst new file mode 100644 index 00000000000000..50dc74c8beeaca --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-05-20-19-10-09.gh-issue-149816.g_ycIN.rst @@ -0,0 +1 @@ +Fix a race condition on ``kwargs`` in ``PyStack_UnpackDict`` by duplicating the ``kwargs`` argument in the ``thread.new_start_thread`` function. diff --git a/Modules/_threadmodule.c b/Modules/_threadmodule.c index 135b53111014d1..c70c454e171868 100644 --- a/Modules/_threadmodule.c +++ b/Modules/_threadmodule.c @@ -385,6 +385,21 @@ thread_run(void *boot_raw) PyEval_AcquireThread(tstate); _Py_atomic_add_ssize(&tstate->interp->threads.count, 1); +#ifdef Py_GIL_DISABLED + // See gh-149816 - (62) Concurrent kwargs growth causes heap overwrite + // So duplicate boot->kwargs to ensure that it won't be mutated concurrently + // by the caller. + if (boot->kwargs != NULL) { + PyObject *n_kwargs = PyDict_Copy(boot->kwargs); + if (n_kwargs == NULL) { + thread_bootstate_free(boot, 1); + goto exit; + } + Py_DECREF(boot->kwargs); // I am not pretty sure about this. + boot->kwargs = n_kwargs; + } +#endif /* Py_GIL_DISABLED */ + PyObject *res = PyObject_Call(boot->func, boot->args, boot->kwargs); if (res == NULL) { if (PyErr_ExceptionMatches(PyExc_SystemExit))