diff --git a/Doc/library/threading.rst b/Doc/library/threading.rst index 5d9a7b6314b166..35ee046fb5b1be 100644 --- a/Doc/library/threading.rst +++ b/Doc/library/threading.rst @@ -567,7 +567,10 @@ since it is impossible to detect the termination of alien threads. of control. This method will raise a :exc:`RuntimeError` if called more than once - on the same thread object. + on the same thread object. It also raises :exc:`RuntimeError` if the + new thread terminates before it has fully started, which can happen + under memory pressure (for example when a :exc:`MemoryError` occurs + while the thread is bootstrapping). If supported, set the operating system thread name to :attr:`threading.Thread.name`. The name can be truncated depending on the @@ -576,6 +579,10 @@ since it is impossible to detect the termination of alien threads. .. versionchanged:: 3.14 Set the operating system thread name. + .. versionchanged:: next + :exc:`RuntimeError` is now raised if the thread dies before it has + fully started; previously this method could hang forever. + .. method:: run() Method representing the thread's activity. diff --git a/Lib/test/test_threading.py b/Lib/test/test_threading.py index 3d01804513bde9..839f11a05fe76d 100644 --- a/Lib/test/test_threading.py +++ b/Lib/test/test_threading.py @@ -1505,6 +1505,55 @@ def run_in_bg(): self.assertEqual(err, b"") self.assertEqual(out.strip(), b"Exiting...") + def test_start_thread_dies_in_bootstrap(self): + # gh-140746: if the newly spawned thread dies before signalling + # that it has started (e.g. a MemoryError while calling _bootstrap), + # Thread.start() must raise instead of hanging forever. + class BrokenBootstrapThread(threading.Thread): + def _bootstrap(self): + # Simulates a failure before self._started.set(): the + # C-level thread_run() catches the exception and the + # thread exits without ever signalling startup. + raise MemoryError('out of memory during bootstrap') + + t = BrokenBootstrapThread(target=lambda: None) + with support.catch_unraisable_exception(): + with self.assertRaisesRegex(RuntimeError, + "thread failed to start"): + t.start() + self.assertFalse(t.is_alive()) + self.assertNotIn(t, threading._limbo) + self.assertNotIn(t, threading.enumerate()) + + def test_start_thread_dies_in_bootstrap_inner(self): + # gh-140746: same as above with the failure inside + # _bootstrap_inner(), before self._started.set(). + t = threading.Thread(target=lambda: None) + with (support.catch_unraisable_exception(), + mock.patch.object(t, '_set_ident', + side_effect=MemoryError('no memory'))): + with self.assertRaisesRegex(RuntimeError, + "thread failed to start"): + t.start() + self.assertFalse(t.is_alive()) + self.assertNotIn(t, threading._limbo) + self.assertNotIn(t, threading.enumerate()) + + def test_start_thread_exits_in_bootstrap(self): + # gh-140746: SystemExit is silently ignored by the C-level + # thread_run(); it must not make Thread.start() hang either. + class ExitingBootstrapThread(threading.Thread): + def _bootstrap(self): + raise SystemExit + + t = ExitingBootstrapThread(target=lambda: None) + with self.assertRaisesRegex(RuntimeError, "thread failed to start"): + t.start() + self.assertFalse(t.is_alive()) + self.assertNotIn(t, threading._limbo) + self.assertNotIn(t, threading.enumerate()) + + class ThreadJoinOnShutdown(BaseTestCase): def _run_and_join(self, script): diff --git a/Lib/threading.py b/Lib/threading.py index abac31e25886fa..c9e5d909fda2d7 100644 --- a/Lib/threading.py +++ b/Lib/threading.py @@ -1144,7 +1144,21 @@ def start(self): with _active_limbo_lock: del _limbo[self] raise - self._started.wait() # Will set ident and native_id + # Wait for the thread to signal that it has started (this also + # ensures that ident and native_id are set). Waiting on the + # Python-level _started event alone can hang forever: if the thread + # dies during its bootstrap (e.g. a MemoryError while calling + # _bootstrap), _started is never set. The C-level event is also + # notified when the thread exits, so this wait cannot hang + # (see gh-140746). + self._os_thread_handle._wait_for_started() + if not self._started.is_set(): + # The thread terminated before signalling that it started: it + # never ran and never will. Clean up and fail loudly rather + # than returning a dead, half-initialized thread. + with _active_limbo_lock: + del _limbo[self] + raise RuntimeError("thread failed to start") def run(self): """Method representing the thread's activity. @@ -1205,6 +1219,9 @@ def _bootstrap_inner(self): self._set_native_id() self._set_os_name() self._started.set() + # Signal the C-level event *after* _started so that a waiter + # woken by it always observes _started as set (gh-140746). + self._os_thread_handle._set_started() with _active_limbo_lock: _active[self._ident] = self del _limbo[self] diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-07-15-10-00-00.gh-issue-140746.limThd.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-07-15-10-00-00.gh-issue-140746.limThd.rst new file mode 100644 index 00000000000000..a5c834bcdaf90e --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-07-15-10-00-00.gh-issue-140746.limThd.rst @@ -0,0 +1,5 @@ +Fix :meth:`threading.Thread.start` hanging forever when the new thread dies +before signalling that it has started, for instance when a +:exc:`MemoryError` is raised during the thread's bootstrap under memory +pressure. :meth:`!Thread.start` now raises :exc:`RuntimeError` in that case +and the dead thread no longer lingers in :func:`threading.enumerate`. diff --git a/Modules/_threadmodule.c b/Modules/_threadmodule.c index e999fe20287e2d..ee08a976bdea16 100644 --- a/Modules/_threadmodule.c +++ b/Modules/_threadmodule.c @@ -146,6 +146,14 @@ typedef struct { // for a more detailed explanation. PyEvent thread_is_exiting; + // Set by the thread when its Python-level bootstrap has signalled + // startup (see threading.Thread._bootstrap_inner), or on thread exit + // if the bootstrap terminated before doing so. threading.Thread.start() + // waits on this event rather than on the Python-level started event + // alone, so that it cannot hang forever if the thread dies during its + // bootstrap (e.g. a MemoryError, see gh-140746). + PyEvent thread_started; + // Serializes calls to `join` and `set_done`. _PyOnceFlag once; @@ -232,6 +240,7 @@ ThreadHandle_new(void) self->os_handle = 0; self->has_os_handle = 0; self->thread_is_exiting = (PyEvent){0}; + self->thread_started = (PyEvent){0}; self->mutex = (PyMutex){_Py_UNLOCKED}; self->once = (_PyOnceFlag){0}; self->state = THREAD_HANDLE_NOT_STARTED; @@ -323,6 +332,7 @@ _PyThread_AfterFork(struct _pythread_runtime_state *state) handle->once = (_PyOnceFlag){_Py_ONCE_INITIALIZED}; handle->mutex = (PyMutex){_Py_UNLOCKED}; _PyEvent_Notify(&handle->thread_is_exiting); + _PyEvent_Notify(&handle->thread_started); llist_remove(node); remove_from_shutdown_handles(handle); } @@ -409,6 +419,12 @@ thread_run(void *boot_raw) // Don't need to wait for this thread anymore remove_from_shutdown_handles(handle); + // gh-140746: Unblock any thread waiting in Thread.start(). If the + // bootstrap function terminated before signalling startup (e.g. a + // MemoryError while calling it), this is the only notification the + // waiter will get. If startup was already signalled, this is a no-op. + _PyEvent_Notify(&handle->thread_started); + _PyEvent_Notify(&handle->thread_is_exiting); ThreadHandle_decref(handle); @@ -708,6 +724,28 @@ PyThreadHandleObject_join(PyObject *op, PyObject *args) Py_RETURN_NONE; } +static PyObject * +PyThreadHandleObject_set_started(PyObject *op, PyObject *Py_UNUSED(dummy)) +{ + PyThreadHandleObject *self = PyThreadHandleObject_CAST(op); + _PyEvent_Notify(&self->handle->thread_started); + Py_RETURN_NONE; +} + +static PyObject * +PyThreadHandleObject_wait_for_started(PyObject *op, PyObject *Py_UNUSED(dummy)) +{ + PyThreadHandleObject *self = PyThreadHandleObject_CAST(op); + while (!PyEvent_WaitTimed(&self->handle->thread_started, -1, + /*detach=*/1)) { + // Interrupted + if (Py_MakePendingCalls() < 0) { + return NULL; + } + } + Py_RETURN_NONE; +} + static PyObject * PyThreadHandleObject_is_done(PyObject *op, PyObject *Py_UNUSED(dummy)) { @@ -741,6 +779,9 @@ static PyGetSetDef ThreadHandle_getsetlist[] = { static PyMethodDef ThreadHandle_methods[] = { {"join", PyThreadHandleObject_join, METH_VARARGS, NULL}, {"_set_done", PyThreadHandleObject_set_done, METH_NOARGS, NULL}, + {"_set_started", PyThreadHandleObject_set_started, METH_NOARGS, NULL}, + {"_wait_for_started", PyThreadHandleObject_wait_for_started, + METH_NOARGS, NULL}, {"is_done", PyThreadHandleObject_is_done, METH_NOARGS, NULL}, {0, 0} };