Skip to content

Commit 4ebbfcf

Browse files
authored
gh-87135: Raise PythonFinalizationError when joining a blocked daemon thread (gh-130402)
If `Py_IsFinalizing()` is true, non-daemon threads (other than the current one) are done, and daemon threads are prevented from running, so they cannot finalize themselves and become done. Joining them (without timeout) would block forever. Raise PythonFinalizationError instead of hanging. Raise even when a timeout is given, for consistency with trying to join your own thread. See gh-123940 for a use case: calling `join()` from `__del__`. This is ill-advised, but an exception should at least make it easier to diagnose.
1 parent 995b1a7 commit 4ebbfcf

File tree

6 files changed

+101
-6
lines changed

6 files changed

+101
-6
lines changed

Doc/c-api/init.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1131,7 +1131,7 @@ Cautions regarding runtime finalization
11311131
In the late stage of :term:`interpreter shutdown`, after attempting to wait for
11321132
non-daemon threads to exit (though this can be interrupted by
11331133
:class:`KeyboardInterrupt`) and running the :mod:`atexit` functions, the runtime
1134-
is marked as *finalizing*: :c:func:`_Py_IsFinalizing` and
1134+
is marked as *finalizing*: :c:func:`Py_IsFinalizing` and
11351135
:func:`sys.is_finalizing` return true. At this point, only the *finalization
11361136
thread* that initiated finalization (typically the main thread) is allowed to
11371137
acquire the :term:`GIL`.

Doc/library/exceptions.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,13 +428,17 @@ The following exceptions are the exceptions that are usually raised.
428428
:exc:`PythonFinalizationError` during the Python finalization:
429429

430430
* Creating a new Python thread.
431+
* :meth:`Joining <threading.Thread.join>` a running daemon thread.
431432
* :func:`os.fork`.
432433

433434
See also the :func:`sys.is_finalizing` function.
434435

435436
.. versionadded:: 3.13
436437
Previously, a plain :exc:`RuntimeError` was raised.
437438

439+
.. versionchanged:: next
440+
441+
:meth:`threading.Thread.join` can now raise this exception.
438442

439443
.. exception:: RecursionError
440444

Doc/library/threading.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,14 @@ since it is impossible to detect the termination of alien threads.
448448
an error to :meth:`~Thread.join` a thread before it has been started
449449
and attempts to do so raise the same exception.
450450

451+
If an attempt is made to join a running daemonic thread in in late stages
452+
of :term:`Python finalization <interpreter shutdown>` :meth:`!join`
453+
raises a :exc:`PythonFinalizationError`.
454+
455+
.. versionchanged:: next
456+
457+
May raise :exc:`PythonFinalizationError`.
458+
451459
.. attribute:: name
452460

453461
A string used for identification purposes only. It has no semantics.

Lib/test/test_threading.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1171,6 +1171,77 @@ def __del__(self):
11711171
self.assertEqual(out.strip(), b"OK")
11721172
self.assertIn(b"can't create new thread at interpreter shutdown", err)
11731173

1174+
def test_join_daemon_thread_in_finalization(self):
1175+
# gh-123940: Py_Finalize() prevents other threads from running Python
1176+
# code, so join() can not succeed unless the thread is already done.
1177+
# (Non-Python threads, that is `threading._DummyThread`, can't be
1178+
# joined at all.)
1179+
# We raise an exception rather than hang.
1180+
for timeout in (None, 10):
1181+
with self.subTest(timeout=timeout):
1182+
code = textwrap.dedent(f"""
1183+
import threading
1184+
1185+
1186+
def loop():
1187+
while True:
1188+
pass
1189+
1190+
1191+
class Cycle:
1192+
def __init__(self):
1193+
self.self_ref = self
1194+
self.thr = threading.Thread(
1195+
target=loop, daemon=True)
1196+
self.thr.start()
1197+
1198+
def __del__(self):
1199+
assert self.thr.is_alive()
1200+
try:
1201+
self.thr.join(timeout={timeout})
1202+
except PythonFinalizationError:
1203+
assert self.thr.is_alive()
1204+
print('got the correct exception!')
1205+
1206+
# Cycle holds a reference to itself, which ensures it is
1207+
# cleaned up during the GC that runs after daemon threads
1208+
# have been forced to exit during finalization.
1209+
Cycle()
1210+
""")
1211+
rc, out, err = assert_python_ok("-c", code)
1212+
self.assertEqual(err, b"")
1213+
self.assertIn(b"got the correct exception", out)
1214+
1215+
def test_join_finished_daemon_thread_in_finalization(self):
1216+
# (see previous test)
1217+
# If the thread is already finished, join() succeeds.
1218+
code = textwrap.dedent("""
1219+
import threading
1220+
done = threading.Event()
1221+
1222+
def loop():
1223+
done.set()
1224+
1225+
1226+
class Cycle:
1227+
def __init__(self):
1228+
self.self_ref = self
1229+
self.thr = threading.Thread(target=loop, daemon=True)
1230+
self.thr.start()
1231+
done.wait()
1232+
1233+
def __del__(self):
1234+
assert not self.thr.is_alive()
1235+
self.thr.join()
1236+
assert not self.thr.is_alive()
1237+
print('all clear!')
1238+
1239+
Cycle()
1240+
""")
1241+
rc, out, err = assert_python_ok("-c", code)
1242+
self.assertEqual(err, b"")
1243+
self.assertIn(b"all clear", out)
1244+
11741245
def test_start_new_thread_failed(self):
11751246
# gh-109746: if Python fails to start newly created thread
11761247
# due to failure of underlying PyThread_start_new_thread() call,
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Joining running daemon threads during interpreter shutdown
2+
now raises :exc:`PythonFinalizationError`.

Modules/_threadmodule.c

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -511,11 +511,21 @@ ThreadHandle_join(ThreadHandle *self, PyTime_t timeout_ns)
511511
// To work around this, we set `thread_is_exiting` immediately before
512512
// `thread_run` returns. We can be sure that we are not attempting to join
513513
// ourselves if the handle's thread is about to exit.
514-
if (!_PyEvent_IsSet(&self->thread_is_exiting) &&
515-
ThreadHandle_ident(self) == PyThread_get_thread_ident_ex()) {
516-
// PyThread_join_thread() would deadlock or error out.
517-
PyErr_SetString(ThreadError, "Cannot join current thread");
518-
return -1;
514+
if (!_PyEvent_IsSet(&self->thread_is_exiting)) {
515+
if (ThreadHandle_ident(self) == PyThread_get_thread_ident_ex()) {
516+
// PyThread_join_thread() would deadlock or error out.
517+
PyErr_SetString(ThreadError, "Cannot join current thread");
518+
return -1;
519+
}
520+
if (Py_IsFinalizing()) {
521+
// gh-123940: On finalization, other threads are prevented from
522+
// running Python code. They cannot finalize themselves,
523+
// so join() would hang forever (or until timeout).
524+
// We raise instead.
525+
PyErr_SetString(PyExc_PythonFinalizationError,
526+
"cannot join thread at interpreter shutdown");
527+
return -1;
528+
}
519529
}
520530

521531
// Wait until the deadline for the thread to exit.

0 commit comments

Comments
 (0)