Skip to content

Commit 3fa40bc

Browse files
gh-158287: Fix blocked asyncio event loop on macOS when child process is stopped
On macOS, waitid(os.P_PID, pid, os.WEXITED | os.WNOWAIT) returns on SIGSTOP with si_code == CLD_STOPPED even though WSTOPPED was not requested. In _ThreadedChildWatcher, consume the stop notification and continue waiting for actual process termination rather than calling _reap_and_notify with os.waitpid(pid, 0) which blocks the event loop thread.
1 parent 37cc8dd commit 3fa40bc

3 files changed

Lines changed: 49 additions & 1 deletion

File tree

‎Lib/asyncio/unix_events.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -968,7 +968,28 @@ 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 (
977+
res is not None
978+
and hasattr(os, 'CLD_STOPPED')
979+
and res.si_code in (os.CLD_STOPPED, getattr(os, 'CLD_TRAPPED', None))
980+
):
981+
consume_flags = getattr(os, 'WSTOPPED', 0)
982+
if hasattr(os, 'WCONTINUED'):
983+
consume_flags |= os.WCONTINUED
984+
if hasattr(os, 'WNOHANG'):
985+
consume_flags |= os.WNOHANG
986+
if consume_flags:
987+
try:
988+
os.waitid(os.P_PID, expected_pid, consume_flags)
989+
except ChildProcessError:
990+
pass
991+
continue
992+
break
972993
except ChildProcessError:
973994
# The child process is already reaped
974995
pass

‎Lib/test/test_asyncio/test_subprocess.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,6 +1162,32 @@ async def run():
11621162

11631163
self.loop.run_until_complete(run())
11641164

1165+
@unittest.skipUnless(hasattr(os, 'waitid'), 'needs os.waitid()')
1166+
@unittest.skipUnless(hasattr(signal, 'SIGSTOP'), 'needs SIGSTOP')
1167+
def test_stopped_child_does_not_block_event_loop(self):
1168+
# gh-158287: on macOS, waitid() with WEXITED incorrectly reports
1169+
# stopped children (si_code == CLD_STOPPED). The child watcher
1170+
# must not mistake this for process termination and attempt to reap
1171+
# the child with waitpid(), which blocks the event loop thread.
1172+
async def run():
1173+
proc = await asyncio.create_subprocess_exec(*PROGRAM_BLOCKED)
1174+
try:
1175+
proc.send_signal(signal.SIGSTOP)
1176+
# Verify event loop remains responsive while child is stopped
1177+
for _ in range(3):
1178+
await asyncio.sleep(0.05)
1179+
self.assertIsNone(proc.returncode)
1180+
finally:
1181+
if hasattr(signal, 'SIGCONT'):
1182+
try:
1183+
proc.send_signal(signal.SIGCONT)
1184+
except ProcessLookupError:
1185+
pass
1186+
proc.kill()
1187+
await proc.wait()
1188+
1189+
self.loop.run_until_complete(run())
1190+
11651191
@unittest.skipUnless(
11661192
unix_events.can_use_pidfd(),
11671193
"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 with ``SIGSTOP`` on macOS caused the event loop to block indefinitely in ``_ThreadedChildWatcher``.

0 commit comments

Comments
 (0)