Skip to content

Commit e8d685f

Browse files
[3.15] gh-158287: Fix blocked asyncio event loop on macOS when child process is stopped (GH-158962) (#160087)
gh-158287: Fix blocked asyncio event loop on macOS when child process is stopped (GH-158962) (cherry picked from commit 231311c) Co-authored-by: Johnny Wilson Dougherty <johnnydougherty09@gmail.com>
1 parent ae62786 commit e8d685f

3 files changed

Lines changed: 68 additions & 1 deletion

File tree

‎Lib/asyncio/unix_events.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -948,7 +948,15 @@ def _do_waitpid(self, loop, expected_pid, callback, args):
948948
# This makes the reaping of the child and notification of the return code
949949
# atomic with respect to the event loop thread.
950950
try:
951-
os.waitid(os.P_PID, expected_pid, os.WEXITED | os.WNOWAIT)
951+
while True:
952+
res = os.waitid(os.P_PID, expected_pid, os.WEXITED | os.WNOWAIT)
953+
# On macOS, waitid() with WEXITED incorrectly returns stopped
954+
# child processes (si_code == CLD_STOPPED). Consume the stop
955+
# notification so we wait for actual process termination.
956+
if res.si_code == os.CLD_STOPPED:
957+
os.waitid(os.P_PID, expected_pid, os.WSTOPPED | os.WNOHANG)
958+
continue
959+
break
952960
except ChildProcessError:
953961
# The child process is already reaped
954962
pass

‎Lib/test/test_asyncio/test_subprocess.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import signal
33
import sys
44
import textwrap
5+
import threading
56
import unittest
67
import warnings
78
from unittest import mock
@@ -1053,6 +1054,63 @@ async def run():
10531054

10541055
self.loop.run_until_complete(run())
10551056

1057+
@unittest.skipUnless(hasattr(os, 'waitid'), 'needs os.waitid()')
1058+
@unittest.skipUnless(hasattr(signal, 'SIGSTOP'), 'needs SIGSTOP')
1059+
def test_stopped_child_does_not_block_event_loop(self):
1060+
# gh-158287: on macOS, waitid() with WEXITED incorrectly reports
1061+
# stopped children (si_code == CLD_STOPPED). The child watcher
1062+
# must not mistake this for process termination and attempt to reap
1063+
# the child with waitpid(), which blocks the event loop thread.
1064+
proc = None
1065+
timed_out = False
1066+
1067+
def watchdog():
1068+
nonlocal timed_out
1069+
timed_out = True
1070+
# If the event loop gets stuck in waitpid() because of this bug,
1071+
# killing the child process unblocks waitpid() so the test fails
1072+
# instead of hanging indefinitely.
1073+
if proc is not None:
1074+
if hasattr(signal, 'SIGCONT'):
1075+
try:
1076+
os.kill(proc.pid, signal.SIGCONT)
1077+
except ProcessLookupError:
1078+
pass
1079+
try:
1080+
os.kill(proc.pid, signal.SIGKILL)
1081+
except ProcessLookupError:
1082+
pass
1083+
1084+
timer = threading.Timer(support.SHORT_TIMEOUT, watchdog)
1085+
timer.start()
1086+
try:
1087+
async def run():
1088+
nonlocal proc
1089+
proc = await asyncio.create_subprocess_exec(*PROGRAM_BLOCKED)
1090+
try:
1091+
proc.send_signal(signal.SIGSTOP)
1092+
# Verify event loop remains responsive while child is stopped
1093+
for _ in range(3):
1094+
await asyncio.sleep(0.05)
1095+
self.assertFalse(timed_out, "event loop was blocked by stopped child")
1096+
self.assertIsNone(proc.returncode)
1097+
finally:
1098+
if hasattr(signal, 'SIGCONT'):
1099+
try:
1100+
proc.send_signal(signal.SIGCONT)
1101+
except ProcessLookupError:
1102+
pass
1103+
try:
1104+
proc.kill()
1105+
except ProcessLookupError:
1106+
pass
1107+
await proc.wait()
1108+
1109+
self.loop.run_until_complete(run())
1110+
finally:
1111+
timer.cancel()
1112+
timer.join()
1113+
10561114
@unittest.skipUnless(
10571115
unix_events.can_use_pidfd(),
10581116
"operating system does not support pidfds",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix a bug in :mod:`asyncio` where stopping a child process (such as with ``SIGSTOP``) on macOS caused the event loop to block indefinitely.

0 commit comments

Comments
 (0)