Skip to content

Commit 231311c

Browse files
gh-158287: Fix blocked asyncio event loop on macOS when child process is stopped (#158962)
1 parent a13d6db commit 231311c

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
@@ -968,7 +968,15 @@ def _do_waitpid(self, loop, expected_pid, callback, args):
968968
# This makes the reaping of the child and notification of the return code
969969
# atomic with respect to the event loop thread.
970970
try:
971-
os.waitid(os.P_PID, expected_pid, os.WEXITED | os.WNOWAIT)
971+
while True:
972+
res = os.waitid(os.P_PID, expected_pid, os.WEXITED | os.WNOWAIT)
973+
# On macOS, waitid() with WEXITED incorrectly returns stopped
974+
# child processes (si_code == CLD_STOPPED). Consume the stop
975+
# notification so we wait for actual process termination.
976+
if res.si_code == os.CLD_STOPPED:
977+
os.waitid(os.P_PID, expected_pid, os.WSTOPPED | os.WNOHANG)
978+
continue
979+
break
972980
except ChildProcessError:
973981
# The child process is already reaped
974982
pass

‎Lib/test/test_asyncio/test_subprocess.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import signal
44
import sys
55
import textwrap
6+
import threading
67
import unittest
78
import warnings
89
from unittest import mock
@@ -1162,6 +1163,63 @@ async def run():
11621163

11631164
self.loop.run_until_complete(run())
11641165

1166+
@unittest.skipUnless(hasattr(os, 'waitid'), 'needs os.waitid()')
1167+
@unittest.skipUnless(hasattr(signal, 'SIGSTOP'), 'needs SIGSTOP')
1168+
def test_stopped_child_does_not_block_event_loop(self):
1169+
# gh-158287: on macOS, waitid() with WEXITED incorrectly reports
1170+
# stopped children (si_code == CLD_STOPPED). The child watcher
1171+
# must not mistake this for process termination and attempt to reap
1172+
# the child with waitpid(), which blocks the event loop thread.
1173+
proc = None
1174+
timed_out = False
1175+
1176+
def watchdog():
1177+
nonlocal timed_out
1178+
timed_out = True
1179+
# If the event loop gets stuck in waitpid() because of this bug,
1180+
# killing the child process unblocks waitpid() so the test fails
1181+
# instead of hanging indefinitely.
1182+
if proc is not None:
1183+
if hasattr(signal, 'SIGCONT'):
1184+
try:
1185+
os.kill(proc.pid, signal.SIGCONT)
1186+
except ProcessLookupError:
1187+
pass
1188+
try:
1189+
os.kill(proc.pid, signal.SIGKILL)
1190+
except ProcessLookupError:
1191+
pass
1192+
1193+
timer = threading.Timer(support.SHORT_TIMEOUT, watchdog)
1194+
timer.start()
1195+
try:
1196+
async def run():
1197+
nonlocal proc
1198+
proc = await asyncio.create_subprocess_exec(*PROGRAM_BLOCKED)
1199+
try:
1200+
proc.send_signal(signal.SIGSTOP)
1201+
# Verify event loop remains responsive while child is stopped
1202+
for _ in range(3):
1203+
await asyncio.sleep(0.05)
1204+
self.assertFalse(timed_out, "event loop was blocked by stopped child")
1205+
self.assertIsNone(proc.returncode)
1206+
finally:
1207+
if hasattr(signal, 'SIGCONT'):
1208+
try:
1209+
proc.send_signal(signal.SIGCONT)
1210+
except ProcessLookupError:
1211+
pass
1212+
try:
1213+
proc.kill()
1214+
except ProcessLookupError:
1215+
pass
1216+
await proc.wait()
1217+
1218+
self.loop.run_until_complete(run())
1219+
finally:
1220+
timer.cancel()
1221+
timer.join()
1222+
11651223
@unittest.skipUnless(
11661224
unix_events.can_use_pidfd(),
11671225
"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)