Skip to content

Commit 93772a0

Browse files
[3.14] gh-127049: fix race condition in asyncio signalling an unrelated process with ThreadedChildWatcher (GH-153810) (#154221)
gh-127049: fix race condition in asyncio signalling an unrelated process with ThreadedChildWatcher (GH-153810) (cherry picked from commit 2875d1d) Co-authored-by: Kumar Aditya <kumaraditya@python.org>
1 parent 67619cf commit 93772a0

4 files changed

Lines changed: 121 additions & 8 deletions

File tree

Lib/asyncio/base_subprocess.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ def kill(self):
165165
else:
166166
def send_signal(self, signal):
167167
self._check_proc()
168+
if self._returncode is not None:
169+
# The process already exited
170+
return
168171
try:
169172
os.kill(self._proc.pid, signal)
170173
except ProcessLookupError:

Lib/asyncio/unix_events.py

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ async def _make_subprocess_transport(self, protocol, args, shell,
218218
return transp
219219

220220
def _child_watcher_callback(self, pid, returncode, transp):
221-
self.call_soon_threadsafe(transp._process_exited, returncode)
221+
transp._process_exited(returncode)
222222

223223
async def create_unix_connection(
224224
self, protocol_factory, path=None, *,
@@ -928,6 +928,49 @@ def add_child_handler(self, pid, callback, *args):
928928
def _do_waitpid(self, loop, expected_pid, callback, args):
929929
assert expected_pid > 0
930930

931+
if hasattr(os, 'waitid'):
932+
# Wait for the child process using waitid() on platforms which support it.
933+
# WNOWAIT is used to avoid reaping the child process, allowing the event loop to
934+
# reap the child process with waitpid() later in event loop thread.
935+
# This makes the reaping of the child and notification of the return code
936+
# atomic with respect to the event loop thread.
937+
try:
938+
os.waitid(os.P_PID, expected_pid, os.WEXITED | os.WNOWAIT)
939+
except ChildProcessError:
940+
# The child process is already reaped
941+
pass
942+
if loop.is_closed():
943+
# loop is already closed, reap the zombie here so that it is not leaked.
944+
pid, _ = self._reap(loop, expected_pid)
945+
logger.warning("Loop %r that handles pid %r is closed",
946+
loop, pid)
947+
else:
948+
try:
949+
loop.call_soon_threadsafe(
950+
self._reap_and_notify, loop, expected_pid,
951+
callback, args)
952+
except RuntimeError:
953+
# The event loop was closed concurrently.
954+
pid, _ = self._reap(loop, expected_pid)
955+
logger.warning("Loop %r that handles pid %r is closed",
956+
loop, pid)
957+
else:
958+
# Fallback for platforms that don't support waitid(): we have to
959+
# reap the child here, which is racy with respect to send_signal()
960+
pid, returncode = self._reap(loop, expected_pid)
961+
if loop.is_closed():
962+
logger.warning("Loop %r that handles pid %r is closed",
963+
loop, pid)
964+
else:
965+
loop.call_soon_threadsafe(callback, pid, returncode, *args)
966+
967+
self._threads.pop(expected_pid)
968+
969+
def _reap_and_notify(self, loop, expected_pid, callback, args):
970+
pid, returncode = self._reap(loop, expected_pid)
971+
callback(pid, returncode, *args)
972+
973+
def _reap(self, loop, expected_pid):
931974
try:
932975
pid, status = os.waitpid(expected_pid, 0)
933976
except ChildProcessError:
@@ -943,13 +986,7 @@ def _do_waitpid(self, loop, expected_pid, callback, args):
943986
if loop.get_debug():
944987
logger.debug('process %s exited with returncode %s',
945988
expected_pid, returncode)
946-
947-
if loop.is_closed():
948-
logger.warning("Loop %r that handles pid %r is closed", loop, pid)
949-
else:
950-
loop.call_soon_threadsafe(callback, pid, returncode, *args)
951-
952-
self._threads.pop(expected_pid)
989+
return pid, returncode
953990

954991
def can_use_pidfd():
955992
if not hasattr(os, 'pidfd_open'):

Lib/test/test_asyncio/test_subprocess.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,45 @@ def test_watcher_implementation(self):
975975
else:
976976
self.assertIsInstance(watcher, unix_events._ThreadedChildWatcher)
977977

978+
@unittest.skipUnless(hasattr(os, 'waitid'), 'needs os.waitid()')
979+
def test_send_signal_never_targets_reaped_pid(self):
980+
# gh-127049: there must be no window between the child watcher
981+
# reaping the child and asyncio publishing the exit in which
982+
# send_signal() signals the freed (possibly recycled) PID.
983+
reaped_pids = set()
984+
stale_kills = []
985+
orig_waitpid = os.waitpid
986+
orig_kill = os.kill
987+
988+
def waitpid(pid, options):
989+
res = orig_waitpid(pid, options)
990+
if res[0] != 0:
991+
reaped_pids.add(res[0])
992+
return res
993+
994+
def kill(pid, sig):
995+
if pid in reaped_pids:
996+
stale_kills.append(pid)
997+
return
998+
orig_kill(pid, sig)
999+
1000+
async def run():
1001+
with mock.patch('os.waitpid', waitpid), \
1002+
mock.patch('os.kill', kill):
1003+
proc = await asyncio.create_subprocess_exec(
1004+
*PROGRAM_BLOCKED)
1005+
proc.kill()
1006+
deadline = self.loop.time() + support.SHORT_TIMEOUT
1007+
while proc.returncode is None:
1008+
if self.loop.time() > deadline:
1009+
self.fail('child exit was not published in time')
1010+
proc.kill()
1011+
await asyncio.sleep(0)
1012+
await proc.wait()
1013+
self.assertEqual(stale_kills, [])
1014+
1015+
self.loop.run_until_complete(run())
1016+
9781017

9791018
class SubprocessThreadedWatcherTests(SubprocessWatcherMixin,
9801019
test_utils.TestCase):
@@ -988,6 +1027,35 @@ def tearDown(self):
9881027
unix_events.can_use_pidfd = self._original_can_use_pidfd
9891028
return super().tearDown()
9901029

1030+
@unittest.skipUnless(hasattr(os, 'waitid'), 'needs os.waitid()')
1031+
def test_pid_not_reaped_before_exit_published(self):
1032+
# gh-127049: the watcher thread must wait for the child
1033+
# without reaping it: the PID must stay reserved until the
1034+
# event loop thread reaps it and publishes the exit as one
1035+
# atomic step.
1036+
async def run():
1037+
proc = await asyncio.create_subprocess_exec(
1038+
*PROGRAM_BLOCKED)
1039+
thread = self.loop._watcher._threads.get(proc.pid)
1040+
self.assertIsNotNone(thread)
1041+
proc.kill()
1042+
# Wait for the watcher thread to observe the exit while
1043+
# the event loop cannot process the notification yet.
1044+
thread.join(support.SHORT_TIMEOUT)
1045+
self.assertFalse(thread.is_alive())
1046+
# The exit has not been published yet...
1047+
self.assertIsNone(proc.returncode)
1048+
# ...so the child must still be an unreaped zombie and
1049+
# signalling its PID must still be safe. waitid() raises
1050+
# ChildProcessError if the PID was already reaped (and
1051+
# possibly recycled by the kernel).
1052+
os.waitid(os.P_PID, proc.pid,
1053+
os.WEXITED | os.WNOWAIT | os.WNOHANG)
1054+
proc.kill()
1055+
self.assertEqual(await proc.wait(), -signal.SIGKILL)
1056+
1057+
self.loop.run_until_complete(run())
1058+
9911059
@unittest.skipUnless(
9921060
unix_events.can_use_pidfd(),
9931061
"operating system does not support pidfds",
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Fix a race condition in :mod:`asyncio` on Unix where
2+
:meth:`asyncio.subprocess.Process.send_signal`, :meth:`~asyncio.subprocess.Process.terminate`
3+
or :meth:`~asyncio.subprocess.Process.kill` could signal an unrelated process
4+
that was recycled onto the PID of the already-reaped child when ThreadedChildWatcher is used.
5+
Patch by Kumar Aditya.

0 commit comments

Comments
 (0)