Skip to content

Commit f764abb

Browse files
miss-islingtonpitroublurb-it[bot]
authored
[3.11] gh-109593: Fix reentrancy issue in multiprocessing resource_tracker (GH-109629) (#109897)
gh-109593: Fix reentrancy issue in multiprocessing resource_tracker (GH-109629) --------- (cherry picked from commit 0eb9883) Co-authored-by: Antoine Pitrou <[email protected]> Co-authored-by: blurb-it[bot] <43283697+blurb-it[bot]@users.noreply.github.com>
1 parent bda6949 commit f764abb

File tree

7 files changed

+95
-2
lines changed

7 files changed

+95
-2
lines changed

Lib/multiprocessing/resource_tracker.py

+32-2
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,31 @@
5151
})
5252

5353

54+
class ReentrantCallError(RuntimeError):
55+
pass
56+
57+
5458
class ResourceTracker(object):
5559

5660
def __init__(self):
57-
self._lock = threading.Lock()
61+
self._lock = threading.RLock()
5862
self._fd = None
5963
self._pid = None
6064

65+
def _reentrant_call_error(self):
66+
# gh-109629: this happens if an explicit call to the ResourceTracker
67+
# gets interrupted by a garbage collection, invoking a finalizer (*)
68+
# that itself calls back into ResourceTracker.
69+
# (*) for example the SemLock finalizer
70+
raise ReentrantCallError(
71+
"Reentrant call into the multiprocessing resource tracker")
72+
6173
def _stop(self):
6274
with self._lock:
75+
# This should not happen (_stop() isn't called by a finalizer)
76+
# but we check for it anyway.
77+
if self._lock._recursion_count() > 1:
78+
return self._reentrant_call_error()
6379
if self._fd is None:
6480
# not running
6581
return
@@ -81,6 +97,9 @@ def ensure_running(self):
8197
This can be run from any process. Usually a child process will use
8298
the resource created by its parent.'''
8399
with self._lock:
100+
if self._lock._recursion_count() > 1:
101+
# The code below is certainly not reentrant-safe, so bail out
102+
return self._reentrant_call_error()
84103
if self._fd is not None:
85104
# resource tracker was launched before, is it still running?
86105
if self._check_alive():
@@ -159,7 +178,17 @@ def unregister(self, name, rtype):
159178
self._send('UNREGISTER', name, rtype)
160179

161180
def _send(self, cmd, name, rtype):
162-
self.ensure_running()
181+
try:
182+
self.ensure_running()
183+
except ReentrantCallError:
184+
# The code below might or might not work, depending on whether
185+
# the resource tracker was already running and still alive.
186+
# Better warn the user.
187+
# (XXX is warnings.warn itself reentrant-safe? :-)
188+
warnings.warn(
189+
f"ResourceTracker called reentrantly for resource cleanup, "
190+
f"which is unsupported. "
191+
f"The {rtype} object {name!r} might leak.")
163192
msg = '{0}:{1}:{2}\n'.format(cmd, name, rtype).encode('ascii')
164193
if len(msg) > 512:
165194
# posix guarantees that writes to a pipe of less than PIPE_BUF
@@ -176,6 +205,7 @@ def _send(self, cmd, name, rtype):
176205
unregister = _resource_tracker.unregister
177206
getfd = _resource_tracker.getfd
178207

208+
179209
def main(fd):
180210
'''Run resource tracker.'''
181211
# protect the process from ^C and "killall python" etc

Lib/test/lock_tests.py

+36
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,42 @@ def test_release_save_unacquired(self):
331331
lock.release()
332332
self.assertRaises(RuntimeError, lock._release_save)
333333

334+
def test_recursion_count(self):
335+
lock = self.locktype()
336+
self.assertEqual(0, lock._recursion_count())
337+
lock.acquire()
338+
self.assertEqual(1, lock._recursion_count())
339+
lock.acquire()
340+
lock.acquire()
341+
self.assertEqual(3, lock._recursion_count())
342+
lock.release()
343+
self.assertEqual(2, lock._recursion_count())
344+
lock.release()
345+
lock.release()
346+
self.assertEqual(0, lock._recursion_count())
347+
348+
phase = []
349+
350+
def f():
351+
lock.acquire()
352+
phase.append(None)
353+
while len(phase) == 1:
354+
_wait()
355+
lock.release()
356+
phase.append(None)
357+
358+
with threading_helper.wait_threads_exit():
359+
start_new_thread(f, ())
360+
while len(phase) == 0:
361+
_wait()
362+
self.assertEqual(len(phase), 1)
363+
self.assertEqual(0, lock._recursion_count())
364+
phase.append(None)
365+
while len(phase) == 2:
366+
_wait()
367+
self.assertEqual(len(phase), 3)
368+
self.assertEqual(0, lock._recursion_count())
369+
334370
def test_different_thread(self):
335371
# Cannot release from a different thread
336372
lock = self.locktype()

Lib/test/test_importlib/test_locks.py

+2
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ class ModuleLockAsRLockTests:
2929
test_timeout = None
3030
# _release_save() unsupported
3131
test_release_save_unacquired = None
32+
# _recursion_count() unsupported
33+
test_recursion_count = None
3234
# lock status in repr unsupported
3335
test_repr = None
3436
test_locked_repr = None

Lib/test/test_threading.py

+3
Original file line numberDiff line numberDiff line change
@@ -1607,6 +1607,9 @@ class ConditionAsRLockTests(lock_tests.RLockTests):
16071607
# Condition uses an RLock by default and exports its API.
16081608
locktype = staticmethod(threading.Condition)
16091609

1610+
def test_recursion_count(self):
1611+
self.skipTest("Condition does not expose _recursion_count()")
1612+
16101613
class ConditionTests(lock_tests.ConditionTests):
16111614
condtype = staticmethod(threading.Condition)
16121615

Lib/threading.py

+7
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,13 @@ def _release_save(self):
218218
def _is_owned(self):
219219
return self._owner == get_ident()
220220

221+
# Internal method used for reentrancy checks
222+
223+
def _recursion_count(self):
224+
if self._owner != get_ident():
225+
return 0
226+
return self._count
227+
221228
_PyRLock = _RLock
222229

223230

Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Avoid deadlocking on a reentrant call to the multiprocessing resource tracker. Such a reentrant call, though unlikely, can happen if a GC pass invokes the finalizer for a multiprocessing object such as SemLock.

Modules/_threadmodule.c

+14
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,18 @@ PyDoc_STRVAR(rlock_release_save_doc,
485485
\n\
486486
For internal use by `threading.Condition`.");
487487

488+
static PyObject *
489+
rlock_recursion_count(rlockobject *self, PyObject *Py_UNUSED(ignored))
490+
{
491+
unsigned long tid = PyThread_get_thread_ident();
492+
return PyLong_FromUnsignedLong(
493+
self->rlock_owner == tid ? self->rlock_count : 0UL);
494+
}
495+
496+
PyDoc_STRVAR(rlock_recursion_count_doc,
497+
"_recursion_count() -> int\n\
498+
\n\
499+
For internal use by reentrancy checks.");
488500

489501
static PyObject *
490502
rlock_is_owned(rlockobject *self, PyObject *Py_UNUSED(ignored))
@@ -560,6 +572,8 @@ static PyMethodDef rlock_methods[] = {
560572
METH_VARARGS, rlock_acquire_restore_doc},
561573
{"_release_save", (PyCFunction)rlock_release_save,
562574
METH_NOARGS, rlock_release_save_doc},
575+
{"_recursion_count", (PyCFunction)rlock_recursion_count,
576+
METH_NOARGS, rlock_recursion_count_doc},
563577
{"__enter__", _PyCFunction_CAST(rlock_acquire),
564578
METH_VARARGS | METH_KEYWORDS, rlock_acquire_doc},
565579
{"__exit__", (PyCFunction)rlock_release,

0 commit comments

Comments
 (0)