diff --git a/Lib/asyncio/unix_events.py b/Lib/asyncio/unix_events.py index 3a66cee93da4f50..ba462e70dbf0af5 100644 --- a/Lib/asyncio/unix_events.py +++ b/Lib/asyncio/unix_events.py @@ -968,7 +968,18 @@ def _do_waitpid(self, loop, expected_pid, callback, args): # This makes the reaping of the child and notification of the return code # atomic with respect to the event loop thread. try: - os.waitid(os.P_PID, expected_pid, os.WEXITED | os.WNOWAIT) + while True: + res = os.waitid(os.P_PID, expected_pid, os.WEXITED | os.WNOWAIT) + # On macOS, waitid() with WEXITED incorrectly returns stopped + # child processes (si_code == CLD_STOPPED). Consume the stop + # notification so we wait for actual process termination. + if res.si_code == os.CLD_STOPPED: + try: + os.waitid(os.P_PID, expected_pid, os.WSTOPPED | os.WNOHANG) + except ChildProcessError: + pass + continue + break except ChildProcessError: # The child process is already reaped pass diff --git a/Lib/test/test_asyncio/test_subprocess.py b/Lib/test/test_asyncio/test_subprocess.py index f9b4fe53acb1343..6e85bdd57c9085b 100644 --- a/Lib/test/test_asyncio/test_subprocess.py +++ b/Lib/test/test_asyncio/test_subprocess.py @@ -3,6 +3,7 @@ import signal import sys import textwrap +import threading import unittest import warnings from unittest import mock @@ -1162,6 +1163,63 @@ async def run(): self.loop.run_until_complete(run()) + @unittest.skipUnless(hasattr(os, 'waitid'), 'needs os.waitid()') + @unittest.skipUnless(hasattr(signal, 'SIGSTOP'), 'needs SIGSTOP') + def test_stopped_child_does_not_block_event_loop(self): + # gh-158287: on macOS, waitid() with WEXITED incorrectly reports + # stopped children (si_code == CLD_STOPPED). The child watcher + # must not mistake this for process termination and attempt to reap + # the child with waitpid(), which blocks the event loop thread. + proc = None + timed_out = False + + def watchdog(): + nonlocal timed_out + timed_out = True + # If the event loop gets stuck in waitpid() because of this bug, + # killing the child process unblocks waitpid() so the test fails + # instead of hanging indefinitely. + if proc is not None: + if hasattr(signal, 'SIGCONT'): + try: + os.kill(proc.pid, signal.SIGCONT) + except ProcessLookupError: + pass + try: + os.kill(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + + timer = threading.Timer(support.SHORT_TIMEOUT, watchdog) + timer.start() + try: + async def run(): + nonlocal proc + proc = await asyncio.create_subprocess_exec(*PROGRAM_BLOCKED) + try: + proc.send_signal(signal.SIGSTOP) + # Verify event loop remains responsive while child is stopped + for _ in range(3): + await asyncio.sleep(0.05) + self.assertFalse(timed_out, "event loop was blocked by stopped child") + self.assertIsNone(proc.returncode) + finally: + if hasattr(signal, 'SIGCONT'): + try: + proc.send_signal(signal.SIGCONT) + except ProcessLookupError: + pass + try: + proc.kill() + except ProcessLookupError: + pass + await proc.wait() + + self.loop.run_until_complete(run()) + finally: + timer.cancel() + timer.join() + @unittest.skipUnless( unix_events.can_use_pidfd(), "operating system does not support pidfds", diff --git a/Misc/NEWS.d/next/Library/2026-10-07-17-30-00.gh-issue-158287.xY9zQp.rst b/Misc/NEWS.d/next/Library/2026-10-07-17-30-00.gh-issue-158287.xY9zQp.rst new file mode 100644 index 000000000000000..d7432faa0b7f92b --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-07-17-30-00.gh-issue-158287.xY9zQp.rst @@ -0,0 +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.