Skip to content

Commit f237efa

Browse files
encukoucolesbury
authored andcommitted
pythongh-87135: Raise PythonFinalizationError when joining a blocked daemon thread
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. See pythongh-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 f963239 commit f237efa

File tree

5 files changed

+150
-21
lines changed

5 files changed

+150
-21
lines changed

Doc/c-api/init.rst

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

Doc/library/exceptions.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,13 +426,18 @@ The following exceptions are the exceptions that are usually raised.
426426
:exc:`PythonFinalizationError` during the Python finalization:
427427

428428
* Creating a new Python thread.
429+
* :meth:`Joining <threading.Thread.join>` a running daemon thread
430+
without a timeout.
429431
* :func:`os.fork`.
430432

431433
See also the :func:`sys.is_finalizing` function.
432434

433435
.. versionadded:: 3.13
434436
Previously, a plain :exc:`RuntimeError` was raised.
435437

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

437442
.. exception:: RecursionError
438443

Doc/library/threading.rst

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

438+
In late stages of :term:`Python finalization <interpreter shutdown>`,
439+
if *timeout* is ``None`` and an attempt is made to join a running
440+
daemonic thread, :meth:`!join` raises a :exc:`PythonFinalizationError`.
441+
(Such a join would block forever: at this point, threads other than the
442+
current one are prevented from running Python code and so they cannot
443+
finalize themselves.)
444+
445+
.. versionchanged:: next
446+
447+
May raise :exc:`PythonFinalizationError`.
448+
438449
.. attribute:: name
439450

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

Lib/test/test_threading.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1171,6 +1171,99 @@ 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+
code = textwrap.dedent("""
1181+
import threading
1182+
1183+
1184+
def loop():
1185+
while True:
1186+
pass
1187+
1188+
1189+
class Cycle:
1190+
def __init__(self):
1191+
self.self_ref = self
1192+
self.thr = threading.Thread(target=loop, daemon=True)
1193+
self.thr.start()
1194+
1195+
def __del__(self):
1196+
try:
1197+
self.thr.join()
1198+
except PythonFinalizationError:
1199+
print('got the correct exception!')
1200+
1201+
# Cycle holds a reference to itself, which ensures it is cleaned
1202+
# up during the GC that runs after daemon threads have been
1203+
# forced to exit during finalization.
1204+
Cycle()
1205+
""")
1206+
rc, out, err = assert_python_ok("-c", code)
1207+
self.assertEqual(err, b"")
1208+
self.assertIn(b"got the correct exception", out)
1209+
1210+
def test_join_finished_daemon_thread_in_finalization(self):
1211+
# (see previous test)
1212+
# If the thread is already finished, join() succeeds.
1213+
code = textwrap.dedent("""
1214+
import threading
1215+
done = threading.Event()
1216+
1217+
def loop():
1218+
done.set()
1219+
1220+
1221+
class Cycle:
1222+
def __init__(self):
1223+
self.self_ref = self
1224+
self.thr = threading.Thread(target=loop, daemon=True)
1225+
self.thr.start()
1226+
done.wait()
1227+
1228+
def __del__(self):
1229+
self.thr.join()
1230+
print('all clear!')
1231+
1232+
Cycle()
1233+
""")
1234+
rc, out, err = assert_python_ok("-c", code)
1235+
self.assertEqual(err, b"")
1236+
self.assertIn(b"all clear", out)
1237+
1238+
def test_timed_join_daemon_thread_in_finalization(self):
1239+
# (see previous test)
1240+
# When called with timeout, no error is raised.
1241+
code = textwrap.dedent("""
1242+
import threading
1243+
done = threading.Event()
1244+
1245+
def loop():
1246+
done.set()
1247+
while True:
1248+
pass
1249+
1250+
class Cycle:
1251+
def __init__(self):
1252+
self.self_ref = self
1253+
self.thr = threading.Thread(target=loop, daemon=True)
1254+
self.thr.start()
1255+
done.wait()
1256+
1257+
def __del__(self):
1258+
self.thr.join(timeout=0.01)
1259+
print('alive:', self.thr.is_alive())
1260+
1261+
Cycle()
1262+
""")
1263+
rc, out, err = assert_python_ok("-c", code)
1264+
self.assertEqual(err, b"")
1265+
self.assertIn(b"alive: True", out)
1266+
11741267
def test_start_new_thread_failed(self):
11751268
# gh-109746: if Python fails to start newly created thread
11761269
# due to failure of underlying PyThread_start_new_thread() call,

Modules/_threadmodule.c

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -510,32 +510,52 @@ ThreadHandle_join(ThreadHandle *self, PyTime_t timeout_ns)
510510
// To work around this, we set `thread_is_exiting` immediately before
511511
// `thread_run` returns. We can be sure that we are not attempting to join
512512
// ourselves if the handle's thread is about to exit.
513-
if (!_PyEvent_IsSet(&self->thread_is_exiting) &&
514-
ThreadHandle_ident(self) == PyThread_get_thread_ident_ex()) {
515-
// PyThread_join_thread() would deadlock or error out.
516-
PyErr_SetString(ThreadError, "Cannot join current thread");
517-
return -1;
518-
}
519-
520-
// Wait until the deadline for the thread to exit.
521-
PyTime_t deadline = timeout_ns != -1 ? _PyDeadline_Init(timeout_ns) : 0;
522-
int detach = 1;
523-
while (!PyEvent_WaitTimed(&self->thread_is_exiting, timeout_ns, detach)) {
524-
if (deadline) {
525-
// _PyDeadline_Get will return a negative value if the deadline has
526-
// been exceeded.
527-
timeout_ns = Py_MAX(_PyDeadline_Get(deadline), 0);
513+
PyEvent *is_exiting = &self->thread_is_exiting;
514+
if (!_PyEvent_IsSet(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;
528519
}
529520

530-
if (timeout_ns) {
531-
// Interrupted
532-
if (Py_MakePendingCalls() < 0) {
521+
PyTime_t deadline = 0;
522+
523+
if (timeout_ns == -1) {
524+
if (Py_IsFinalizing()) {
525+
// gh-123940: On finalization, other threads are prevented from
526+
// running Python code. They cannot finalize themselves,
527+
// so join() would hang forever.
528+
// We raise instead.
529+
// (We only do this if no timeout is given: otherwise
530+
// we assume the caller can handle a hung thread.)
531+
PyErr_SetString(PyExc_PythonFinalizationError,
532+
"cannot join thread at interpreter shutdown");
533533
return -1;
534534
}
535535
}
536536
else {
537-
// Timed out
538-
return 0;
537+
deadline = _PyDeadline_Init(timeout_ns);
538+
}
539+
540+
// Wait until the deadline for the thread to exit.
541+
int detach = 1;
542+
while (!PyEvent_WaitTimed(is_exiting, timeout_ns, detach)) {
543+
if (deadline) {
544+
// _PyDeadline_Get will return a negative value if
545+
// the deadline has been exceeded.
546+
timeout_ns = Py_MAX(_PyDeadline_Get(deadline), 0);
547+
}
548+
549+
if (timeout_ns) {
550+
// Interrupted
551+
if (Py_MakePendingCalls() < 0) {
552+
return -1;
553+
}
554+
}
555+
else {
556+
// Timed out
557+
return 0;
558+
}
539559
}
540560
}
541561

0 commit comments

Comments
 (0)