From 288e4dbdc525cbdb135483b39e929f79c022f655 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Sun, 18 Jan 2026 20:51:22 +0100 Subject: [PATCH 01/56] Use pidfd_open() and save the FD as an attribute --- Lib/subprocess.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 3cebd7883fcf29..2d3c5867932e86 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -871,6 +871,7 @@ def __init__(self, args, bufsize=-1, executable=None, self.stdout = None self.stderr = None self.pid = None + self._pidfd = None self.returncode = None self.encoding = encoding self.errors = errors @@ -1817,6 +1818,12 @@ def _posix_spawn(self, args, executable, env, restore_signals, close_fds, self.pid = os.posix_spawn(executable, args, env, **kwargs) self._child_created = True + if hasattr(os, "pidfd_open"): + try: + self._pidfd = os.pidfd_open(self.pid, 0) + except OSError: + pass + self._close_pipe_fds(p2cread, p2cwrite, c2pread, c2pwrite, errread, errwrite) @@ -2046,6 +2053,15 @@ def _try_wait(self, wait_flags): sts = 0 return (pid, sts) + def _wait_pidfd(self, timeout): + if self._pidfd is None: + return False # fallback + poller = select.poll() + poller.register(self._pidfd, select.POLLIN) + events = poller.poll(int(timeout * 1000)) + if not events: + raise TimeoutExpired(self.args, timeout) + return True def _wait(self, timeout): """Internal implementation of wait() on POSIX.""" From 6e494caf005ffb7409c8aed694b6a7a7e25c84af Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Sun, 18 Jan 2026 21:02:07 +0100 Subject: [PATCH 02/56] Create _busy_wait() and move code in there --- Lib/subprocess.py | 45 ++++++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 2d3c5867932e86..113a603f207c38 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2063,33 +2063,36 @@ def _wait_pidfd(self, timeout): raise TimeoutExpired(self.args, timeout) return True + def _busy_wait(self, timeout): + endtime = _time() + timeout + # Enter a busy loop if we have a timeout. This busy loop was + # cribbed from Lib/threading.py in Thread.wait() at r71065. + delay = 0.0005 # 500 us -> initial delay of 1 ms + while True: + if self._waitpid_lock.acquire(False): + try: + if self.returncode is not None: + break # Another thread waited. + (pid, sts) = self._try_wait(os.WNOHANG) + assert pid == self.pid or pid == 0 + if pid == self.pid: + self._handle_exitstatus(sts) + break + finally: + self._waitpid_lock.release() + remaining = self._remaining_time(endtime) + if remaining <= 0: + raise TimeoutExpired(self.args, timeout) + delay = min(delay * 2, remaining, .05) + time.sleep(delay) + def _wait(self, timeout): """Internal implementation of wait() on POSIX.""" if self.returncode is not None: return self.returncode if timeout is not None: - endtime = _time() + timeout - # Enter a busy loop if we have a timeout. This busy loop was - # cribbed from Lib/threading.py in Thread.wait() at r71065. - delay = 0.0005 # 500 us -> initial delay of 1 ms - while True: - if self._waitpid_lock.acquire(False): - try: - if self.returncode is not None: - break # Another thread waited. - (pid, sts) = self._try_wait(os.WNOHANG) - assert pid == self.pid or pid == 0 - if pid == self.pid: - self._handle_exitstatus(sts) - break - finally: - self._waitpid_lock.release() - remaining = self._remaining_time(endtime) - if remaining <= 0: - raise TimeoutExpired(self.args, timeout) - delay = min(delay * 2, remaining, .05) - time.sleep(delay) + self._busy_wait(timeout) else: while self.returncode is None: with self._waitpid_lock: From aaf193cb22ec4614dbb8538843627b47687edec1 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Sun, 18 Jan 2026 21:17:33 +0100 Subject: [PATCH 03/56] Move existing code in new _blocking_wait() method --- Lib/subprocess.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 113a603f207c38..66e4511ef5ba95 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2086,6 +2086,18 @@ def _busy_wait(self, timeout): delay = min(delay * 2, remaining, .05) time.sleep(delay) + def _blocking_wait(self): + while self.returncode is None: + with self._waitpid_lock: + if self.returncode is not None: + break # Another thread waited. + (pid, sts) = self._try_wait(0) + # Check the pid and loop as waitpid has been known to + # return 0 even without WNOHANG in odd situations. + # http://bugs.python.org/issue14396. + if pid == self.pid: + self._handle_exitstatus(sts) + def _wait(self, timeout): """Internal implementation of wait() on POSIX.""" if self.returncode is not None: @@ -2094,16 +2106,7 @@ def _wait(self, timeout): if timeout is not None: self._busy_wait(timeout) else: - while self.returncode is None: - with self._waitpid_lock: - if self.returncode is not None: - break # Another thread waited. - (pid, sts) = self._try_wait(0) - # Check the pid and loop as waitpid has been known to - # return 0 even without WNOHANG in odd situations. - # http://bugs.python.org/issue14396. - if pid == self.pid: - self._handle_exitstatus(sts) + self._blocking_wait() return self.returncode From 7cbb0ad5b466d2cba4c0b67ff0821152b5b6b2a1 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Sun, 18 Jan 2026 21:26:29 +0100 Subject: [PATCH 04/56] Use _wait_pidfd() --- Lib/subprocess.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 66e4511ef5ba95..09c213a7706bfb 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2104,7 +2104,12 @@ def _wait(self, timeout): return self.returncode if timeout is not None: - self._busy_wait(timeout) + if timeout < 0: + raise TimeoutExpired(self.args, timeout) + if self._wait_pidfd(timeout): + self._blocking_wait() + else: + self._busy_wait(timeout) else: self._blocking_wait() return self.returncode From d9760c381efa574f859060c8b143c88d7865ed3a Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Sun, 18 Jan 2026 21:28:48 +0100 Subject: [PATCH 05/56] Use pidfd_open() not at class level but at method level --- Lib/subprocess.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 09c213a7706bfb..61ce4308bdfaaa 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -871,7 +871,6 @@ def __init__(self, args, bufsize=-1, executable=None, self.stdout = None self.stderr = None self.pid = None - self._pidfd = None self.returncode = None self.encoding = encoding self.errors = errors @@ -1818,12 +1817,6 @@ def _posix_spawn(self, args, executable, env, restore_signals, close_fds, self.pid = os.posix_spawn(executable, args, env, **kwargs) self._child_created = True - if hasattr(os, "pidfd_open"): - try: - self._pidfd = os.pidfd_open(self.pid, 0) - except OSError: - pass - self._close_pipe_fds(p2cread, p2cwrite, c2pread, c2pwrite, errread, errwrite) @@ -2054,14 +2047,21 @@ def _try_wait(self, wait_flags): return (pid, sts) def _wait_pidfd(self, timeout): - if self._pidfd is None: - return False # fallback - poller = select.poll() - poller.register(self._pidfd, select.POLLIN) - events = poller.poll(int(timeout * 1000)) - if not events: - raise TimeoutExpired(self.args, timeout) - return True + if not hasattr(os, "pidfd_open"): + return False + try: + pidfd = os.pidfd_open(self.pid, 0) + except OSError: + return False + try: + poller = select.poll() + poller.register(pidfd, select.POLLIN) + events = poller.poll(int(timeout * 1000)) + if not events: + raise TimeoutExpired(self.args, timeout) + return True + finally: + os.close(pidfd) def _busy_wait(self, timeout): endtime = _time() + timeout From 5fd8eecd0b63cbe6a06300860924f1310d1979e6 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Sun, 18 Jan 2026 22:00:33 +0100 Subject: [PATCH 06/56] Add kqueue() implementation for macOS and BSD --- Lib/subprocess.py | 46 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 61ce4308bdfaaa..679727595fa778 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -748,6 +748,18 @@ def _use_posix_spawn(): return False +@functools.lru_cache +def _can_use_kqueue(): + names = ( + "kqueue", + "KQ_EV_ADD", + "KQ_EV_ONESHOT", + "KQ_FILTER_PROC", + "KQ_NOTE_EXIT", + ) + return all(hasattr(select, x) for x in names) + + # These are primarily fail-safe knobs for negatives. A True value does not # guarantee the given libc/syscall API will be used. _USE_POSIX_SPAWN = _use_posix_spawn() @@ -2063,6 +2075,38 @@ def _wait_pidfd(self, timeout): finally: os.close(pidfd) + def _wait_kqueue(self, timeout): + """Wait for PID to terminate using kqueue(). macOS and BSD only.""" + if not _can_use_kqueue(): + return False + + try: + kq = select.kqueue() + except OSError as err: + if err.errno in {errno.EMFILE, errno.ENFILE}: # too many open files + return False + raise + + try: + kev = select.kevent( + self.pid, + filter=select.KQ_FILTER_PROC, + flags=select.KQ_EV_ADD | select.KQ_EV_ONESHOT, + fflags=select.KQ_NOTE_EXIT, + ) + try: + events = kq.control([kev], 1, timeout) # wait + except OSError as err: + if err.errno in {errno.EACCES, errno.EPERM, errno.ESRCH}: + return False + raise + else: + if not events: + raise TimeoutExpired(timeout) + return True + finally: + kq.close() + def _busy_wait(self, timeout): endtime = _time() + timeout # Enter a busy loop if we have a timeout. This busy loop was @@ -2106,7 +2150,7 @@ def _wait(self, timeout): if timeout is not None: if timeout < 0: raise TimeoutExpired(self.args, timeout) - if self._wait_pidfd(timeout): + if self._wait_pidfd(timeout) or self._wait_kqueue(timeout): self._blocking_wait() else: self._busy_wait(timeout) From 100b11135060a216de3701cc73860a36c635c771 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Sun, 18 Jan 2026 22:03:36 +0100 Subject: [PATCH 07/56] Add docstrings --- Lib/subprocess.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 679727595fa778..72e4fe07889299 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2059,6 +2059,9 @@ def _try_wait(self, wait_flags): return (pid, sts) def _wait_pidfd(self, timeout): + """Wait for PID to terminate using pidfd_open() + poll(). + Linux >= 5.3 only. + """ if not hasattr(os, "pidfd_open"): return False try: @@ -2083,7 +2086,8 @@ def _wait_kqueue(self, timeout): try: kq = select.kqueue() except OSError as err: - if err.errno in {errno.EMFILE, errno.ENFILE}: # too many open files + # too many open files + if err.errno in {errno.EMFILE, errno.ENFILE}: return False raise From 74ac2f458bb11325ef38dbaf3f83c08dff793b79 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Sun, 18 Jan 2026 22:16:56 +0100 Subject: [PATCH 08/56] Be conservative and check for specific error codes --- Lib/subprocess.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 72e4fe07889299..1f817342ebb64e 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2068,6 +2068,7 @@ def _wait_pidfd(self, timeout): pidfd = os.pidfd_open(self.pid, 0) except OSError: return False + try: poller = select.poll() poller.register(pidfd, select.POLLIN) @@ -2082,14 +2083,10 @@ def _wait_kqueue(self, timeout): """Wait for PID to terminate using kqueue(). macOS and BSD only.""" if not _can_use_kqueue(): return False - try: kq = select.kqueue() except OSError as err: - # too many open files - if err.errno in {errno.EMFILE, errno.ENFILE}: - return False - raise + return False try: kev = select.kevent( @@ -2101,13 +2098,10 @@ def _wait_kqueue(self, timeout): try: events = kq.control([kev], 1, timeout) # wait except OSError as err: - if err.errno in {errno.EACCES, errno.EPERM, errno.ESRCH}: - return False - raise - else: - if not events: - raise TimeoutExpired(timeout) - return True + return False + if not events: + raise TimeoutExpired(timeout) + return True finally: kq.close() From fc0cfd6d8361379f021b229bab8b34e50a2b71e3 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Sun, 18 Jan 2026 22:24:31 +0100 Subject: [PATCH 09/56] Document possible failures --- Lib/subprocess.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 1f817342ebb64e..6cf67c0a7379a2 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2067,6 +2067,13 @@ def _wait_pidfd(self, timeout): try: pidfd = os.pidfd_open(self.pid, 0) except OSError: + # May be: + # - ESRCH: no such process; waitpid() should still be + # able to return the status code. + # - EMFILE, ENFILE: too many open files (usually 1024) + # - ENODEV: anonymous inode filesystem not supported + # - EPERM, EACCES, ENOSYS: undocumented; may happen if + # blocked by security policy like SECCOMP return False try: @@ -2085,7 +2092,8 @@ def _wait_kqueue(self, timeout): return False try: kq = select.kqueue() - except OSError as err: + except OSError: + # usually EMFILE / ENFILE (too many open files) return False try: From f28245954201aacb9fbe91631c2d410b5eb15dee Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Sun, 18 Jan 2026 22:25:32 +0100 Subject: [PATCH 10/56] Add missing import --- Lib/subprocess.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 6cf67c0a7379a2..e667ecc95f7a60 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -51,6 +51,7 @@ import threading import warnings import contextlib +import functools from time import monotonic as _time import types From 612597694f46b4ecdab67f352dd8a8d908c8ec04 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Sun, 18 Jan 2026 23:49:06 +0100 Subject: [PATCH 11/56] Write test for pidfd_open() failing --- Lib/test/test_subprocess.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index 806a1e3fa303eb..d9d0f923989e7c 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -1428,6 +1428,18 @@ def test_wait_timeout(self): self.assertIn("0.0001", str(c.exception)) # For coverage of __str__. self.assertEqual(p.wait(timeout=support.SHORT_TIMEOUT), 0) + + @unittest.skipIf(not hasattr(os, "pidfd_open"), reason="LINUX only") + def test_wait_pidfd_open_error(self): + exc = OSError(errno.EMFILE, os.strerror(errno.EMFILE)) + with mock.patch("os.pidfd_open", side_effect=exc) as m: + p = subprocess.Popen([sys.executable, + "-c", "import time; time.sleep(0.3)"]) + with self.assertRaises(subprocess.TimeoutExpired) as c: + p.wait(timeout=0.0001) + self.assertEqual(p.wait(timeout=support.SHORT_TIMEOUT), 0) + assert m.called + def test_invalid_bufsize(self): # an invalid type of the bufsize argument should raise # TypeError. From 41dc6c0153a80a6d9b12380fe5973799f50854c7 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 00:01:42 +0100 Subject: [PATCH 12/56] Write test case for pidfd_open() / kqueue failing. Assert fallback is used. --- Lib/subprocess.py | 2 +- Lib/test/test_subprocess.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index e667ecc95f7a60..b31750d23fc073 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2109,7 +2109,7 @@ def _wait_kqueue(self, timeout): except OSError as err: return False if not events: - raise TimeoutExpired(timeout) + raise TimeoutExpired(self.args, timeout) return True finally: kq.close() diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index d9d0f923989e7c..bc1d8ea7685fd5 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -1430,9 +1430,11 @@ def test_wait_timeout(self): @unittest.skipIf(not hasattr(os, "pidfd_open"), reason="LINUX only") - def test_wait_pidfd_open_error(self): + def test_wait_pidfd_open_error(self, patch_point="os.pidfd_open"): + # Emulate a case where pidfd_open() fails due to too many open + # files. _busy_wait() should be used as fallback. exc = OSError(errno.EMFILE, os.strerror(errno.EMFILE)) - with mock.patch("os.pidfd_open", side_effect=exc) as m: + with mock.patch(patch_point, side_effect=exc) as m: p = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(0.3)"]) with self.assertRaises(subprocess.TimeoutExpired) as c: @@ -1440,6 +1442,12 @@ def test_wait_pidfd_open_error(self): self.assertEqual(p.wait(timeout=support.SHORT_TIMEOUT), 0) assert m.called + @unittest.skipIf( + not subprocess._can_use_kqueue(), reason="macOS / BSD only" + ) + def test_wait_kqueue_error(self, patch_point="os.pidfd_open"): + self.test_wait_pidfd_open_error(patch_point="select.kqueue") + def test_invalid_bufsize(self): # an invalid type of the bufsize argument should raise # TypeError. From 73ba3802f31cea665a9c093e563999220ce8d267 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 00:24:07 +0100 Subject: [PATCH 13/56] Write test case for kqueue failing. Assert fallback is used. --- Lib/test/test_subprocess.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index bc1d8ea7685fd5..1f6593ce97d9b4 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -1428,11 +1428,10 @@ def test_wait_timeout(self): self.assertIn("0.0001", str(c.exception)) # For coverage of __str__. self.assertEqual(p.wait(timeout=support.SHORT_TIMEOUT), 0) - - @unittest.skipIf(not hasattr(os, "pidfd_open"), reason="LINUX only") - def test_wait_pidfd_open_error(self, patch_point="os.pidfd_open"): - # Emulate a case where pidfd_open() fails due to too many open - # files. _busy_wait() should be used as fallback. + def assert_fast_waitpid_error(self, patch_point): + # Emulate a case where pidfd_open() (Linux) or kqueue() + # (BSD/macOS) fails due to too many open files. _busy_wait() + # should be used as fallback. exc = OSError(errno.EMFILE, os.strerror(errno.EMFILE)) with mock.patch(patch_point, side_effect=exc) as m: p = subprocess.Popen([sys.executable, @@ -1442,11 +1441,15 @@ def test_wait_pidfd_open_error(self, patch_point="os.pidfd_open"): self.assertEqual(p.wait(timeout=support.SHORT_TIMEOUT), 0) assert m.called + @unittest.skipIf(not hasattr(os, "pidfd_open"), reason="LINUX only") + def test_wait_pidfd_open_error(self, patch_point="os.pidfd_open"): + self.assert_fast_waitpid_error("os.pidfd_open") + @unittest.skipIf( not subprocess._can_use_kqueue(), reason="macOS / BSD only" ) def test_wait_kqueue_error(self, patch_point="os.pidfd_open"): - self.test_wait_pidfd_open_error(patch_point="select.kqueue") + self.assert_fast_waitpid_error("select.kqueue") def test_invalid_bufsize(self): # an invalid type of the bufsize argument should raise From 3c8e60367487a9d2858b9238e26dca4c057ebf54 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 00:28:36 +0100 Subject: [PATCH 14/56] Move tests in their own class --- Lib/test/test_subprocess.py | 49 ++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index 1f6593ce97d9b4..1b3b45a1540f2c 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -1428,29 +1428,6 @@ def test_wait_timeout(self): self.assertIn("0.0001", str(c.exception)) # For coverage of __str__. self.assertEqual(p.wait(timeout=support.SHORT_TIMEOUT), 0) - def assert_fast_waitpid_error(self, patch_point): - # Emulate a case where pidfd_open() (Linux) or kqueue() - # (BSD/macOS) fails due to too many open files. _busy_wait() - # should be used as fallback. - exc = OSError(errno.EMFILE, os.strerror(errno.EMFILE)) - with mock.patch(patch_point, side_effect=exc) as m: - p = subprocess.Popen([sys.executable, - "-c", "import time; time.sleep(0.3)"]) - with self.assertRaises(subprocess.TimeoutExpired) as c: - p.wait(timeout=0.0001) - self.assertEqual(p.wait(timeout=support.SHORT_TIMEOUT), 0) - assert m.called - - @unittest.skipIf(not hasattr(os, "pidfd_open"), reason="LINUX only") - def test_wait_pidfd_open_error(self, patch_point="os.pidfd_open"): - self.assert_fast_waitpid_error("os.pidfd_open") - - @unittest.skipIf( - not subprocess._can_use_kqueue(), reason="macOS / BSD only" - ) - def test_wait_kqueue_error(self, patch_point="os.pidfd_open"): - self.assert_fast_waitpid_error("select.kqueue") - def test_invalid_bufsize(self): # an invalid type of the bufsize argument should raise # TypeError. @@ -4117,5 +4094,31 @@ def test_broken_pipe_cleanup(self): self.assertTrue(proc.stdin.closed) +class FastWaitTestCase(BaseTestCase): + + def assert_fast_waitpid_error(self, patch_point): + # Emulate a case where pidfd_open() (Linux) or kqueue() + # (BSD/macOS) fails due to too many open files. _busy_wait() + # should be used as fallback. + exc = OSError(errno.EMFILE, os.strerror(errno.EMFILE)) + with mock.patch(patch_point, side_effect=exc) as m: + p = subprocess.Popen([sys.executable, + "-c", "import time; time.sleep(0.3)"]) + with self.assertRaises(subprocess.TimeoutExpired) as c: + p.wait(timeout=0.0001) + self.assertEqual(p.wait(timeout=support.SHORT_TIMEOUT), 0) + assert m.called + + @unittest.skipIf(not hasattr(os, "pidfd_open"), reason="LINUX only") + def test_wait_pidfd_open_error(self, patch_point="os.pidfd_open"): + self.assert_fast_waitpid_error("os.pidfd_open") + + @unittest.skipIf( + not subprocess._can_use_kqueue(), reason="macOS / BSD only" + ) + def test_wait_kqueue_error(self, patch_point="os.pidfd_open"): + self.assert_fast_waitpid_error("select.kqueue") + + if __name__ == "__main__": unittest.main() From 932ae58cb5c5c688688cb81362935837f09caafa Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 00:45:10 +0100 Subject: [PATCH 15/56] Add test for terminated PID race --- Lib/test/test_subprocess.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index 1b3b45a1540f2c..a8f9dcb1cb63a5 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -4104,7 +4104,7 @@ def assert_fast_waitpid_error(self, patch_point): with mock.patch(patch_point, side_effect=exc) as m: p = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(0.3)"]) - with self.assertRaises(subprocess.TimeoutExpired) as c: + with self.assertRaises(subprocess.TimeoutExpired): p.wait(timeout=0.0001) self.assertEqual(p.wait(timeout=support.SHORT_TIMEOUT), 0) assert m.called @@ -4119,6 +4119,30 @@ def test_wait_pidfd_open_error(self, patch_point="os.pidfd_open"): def test_wait_kqueue_error(self, patch_point="os.pidfd_open"): self.assert_fast_waitpid_error("select.kqueue") + # --- + + def assert_wait_pid_race(self, patch_target, real_func): + # Call pidfd_open() / kqueue, then terminate the process. Make + # sure that the next poll() / kqueue.control() call still works + # for a terminated PID. + p = subprocess.Popen([sys.executable, + "-c", "import time; time.sleep(0.3)"]) + + def wrapper(*args, **kwargs): + ret = real_func(*args, **kwargs) + os.kill(p.pid, signal.SIGTERM) + return ret + + with mock.patch(patch_target, side_effect=wrapper) as m: + with self.assertRaises(subprocess.TimeoutExpired): + p.wait(timeout=0.0001) + assert m.called + self.assertEqual(p.wait(timeout=support.SHORT_TIMEOUT), -signal.SIGTERM) + + @unittest.skipIf(not hasattr(os, "pidfd_open"), reason="LINUX only") + def test_pidfd_open_race(self): + self.assert_wait_pid_race("os.pidfd_open", os.pidfd_open) + if __name__ == "__main__": unittest.main() From bb5080afc98da5f0573800f754b096d0915cb293 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 01:19:44 +0100 Subject: [PATCH 16/56] Add test_kqueue_race() --- Lib/test/test_subprocess.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index a8f9dcb1cb63a5..32fcb80861cfae 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -4131,18 +4131,24 @@ def assert_wait_pid_race(self, patch_target, real_func): def wrapper(*args, **kwargs): ret = real_func(*args, **kwargs) os.kill(p.pid, signal.SIGTERM) + os.waitpid(p.pid, 0) return ret with mock.patch(patch_target, side_effect=wrapper) as m: - with self.assertRaises(subprocess.TimeoutExpired): - p.wait(timeout=0.0001) + status = p.wait(timeout=support.SHORT_TIMEOUT) assert m.called - self.assertEqual(p.wait(timeout=support.SHORT_TIMEOUT), -signal.SIGTERM) + self.assertEqual(status, 0) @unittest.skipIf(not hasattr(os, "pidfd_open"), reason="LINUX only") def test_pidfd_open_race(self): self.assert_wait_pid_race("os.pidfd_open", os.pidfd_open) + @unittest.skipIf( + not subprocess._can_use_kqueue(), reason="macOS / BSD only" + ) + def test_kqueue_race(self): + self.assert_wait_pid_race("select.kqueue", select.kqueue) + if __name__ == "__main__": unittest.main() From f067073c888ed532be9661655534b21619f9c3c2 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 01:26:06 +0100 Subject: [PATCH 17/56] Add test_kqueue_control_error --- Lib/test/test_subprocess.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index 32fcb80861cfae..6c7cc59aaf1adf 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -4119,12 +4119,31 @@ def test_wait_pidfd_open_error(self, patch_point="os.pidfd_open"): def test_wait_kqueue_error(self, patch_point="os.pidfd_open"): self.assert_fast_waitpid_error("select.kqueue") - # --- + @unittest.skipIf( + not subprocess._can_use_kqueue(), reason="macOS / BSD only" + ) + def test_kqueue_control_error(self): + # Emulate a case where kqueue.control() fails due to too many + # open files. _busy_wait() should be used as fallback. + p = subprocess.Popen([sys.executable, + "-c", "import time; time.sleep(0.3)"]) + kq_mock = mock.Mock() + kq_mock.control.side_effect = OSError( + errno.EPERM, os.strerror(errno.EPERM) + ) + kq_mock.close = mock.Mock() + + with mock.patch("select.kqueue", return_value=kq_mock) as m: + with self.assertRaises(subprocess.TimeoutExpired): + p.wait(timeout=0.0001) + self.assertEqual(p.wait(timeout=support.SHORT_TIMEOUT), 0) + assert m.called + def assert_wait_pid_race(self, patch_target, real_func): - # Call pidfd_open() / kqueue, then terminate the process. Make - # sure that the next poll() / kqueue.control() call still works - # for a terminated PID. + # Call pidfd_open() / kqueue(), then terminate the process. + # Make sure that the next poll() / kqueue.control() call still + # works for a terminated PID. p = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(0.3)"]) From 4d96c2f0bcd1792d0d7ec258031ec035ce27fcf6 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 01:33:22 +0100 Subject: [PATCH 18/56] Add docstring --- Lib/test/test_subprocess.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index 6c7cc59aaf1adf..823d18461d459b 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -4095,11 +4095,13 @@ def test_broken_pipe_cleanup(self): class FastWaitTestCase(BaseTestCase): + """Tests for efficient (pidfd_open / kqueue) process waiting in + subprocess.Popen.wait(). + """ def assert_fast_waitpid_error(self, patch_point): # Emulate a case where pidfd_open() (Linux) or kqueue() - # (BSD/macOS) fails due to too many open files. _busy_wait() - # should be used as fallback. + # (BSD/macOS) fails. _busy_wait() should be used as fallback. exc = OSError(errno.EMFILE, os.strerror(errno.EMFILE)) with mock.patch(patch_point, side_effect=exc) as m: p = subprocess.Popen([sys.executable, @@ -4110,21 +4112,21 @@ def assert_fast_waitpid_error(self, patch_point): assert m.called @unittest.skipIf(not hasattr(os, "pidfd_open"), reason="LINUX only") - def test_wait_pidfd_open_error(self, patch_point="os.pidfd_open"): + def test_wait_pidfd_open_error(self): self.assert_fast_waitpid_error("os.pidfd_open") @unittest.skipIf( not subprocess._can_use_kqueue(), reason="macOS / BSD only" ) - def test_wait_kqueue_error(self, patch_point="os.pidfd_open"): + def test_wait_kqueue_error(self): self.assert_fast_waitpid_error("select.kqueue") @unittest.skipIf( not subprocess._can_use_kqueue(), reason="macOS / BSD only" ) def test_kqueue_control_error(self): - # Emulate a case where kqueue.control() fails due to too many - # open files. _busy_wait() should be used as fallback. + # Emulate a case where kqueue.control() fails. _busy_wait() + # should be used as fallback. p = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(0.3)"]) kq_mock = mock.Mock() @@ -4139,11 +4141,10 @@ def test_kqueue_control_error(self): self.assertEqual(p.wait(timeout=support.SHORT_TIMEOUT), 0) assert m.called - - def assert_wait_pid_race(self, patch_target, real_func): + def assert_wait_race_condition(self, patch_target, real_func): # Call pidfd_open() / kqueue(), then terminate the process. - # Make sure that the next poll() / kqueue.control() call still - # works for a terminated PID. + # Make sure that the wait call (poll() / kqueue.control()) + # still works for a terminated PID. p = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(0.3)"]) @@ -4160,13 +4161,13 @@ def wrapper(*args, **kwargs): @unittest.skipIf(not hasattr(os, "pidfd_open"), reason="LINUX only") def test_pidfd_open_race(self): - self.assert_wait_pid_race("os.pidfd_open", os.pidfd_open) + self.assert_wait_race_condition("os.pidfd_open", os.pidfd_open) @unittest.skipIf( not subprocess._can_use_kqueue(), reason="macOS / BSD only" ) def test_kqueue_race(self): - self.assert_wait_pid_race("select.kqueue", select.kqueue) + self.assert_wait_race_condition("select.kqueue", select.kqueue) if __name__ == "__main__": From fe05acc57a75b4ce3b806dd33422ccb6e62c0f83 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 01:34:10 +0100 Subject: [PATCH 19/56] Guard against possible slow test --- Lib/test/test_subprocess.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index 823d18461d459b..901f6289fe67f0 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -4150,8 +4150,11 @@ def assert_wait_race_condition(self, patch_target, real_func): def wrapper(*args, **kwargs): ret = real_func(*args, **kwargs) - os.kill(p.pid, signal.SIGTERM) - os.waitpid(p.pid, 0) + try: + os.kill(p.pid, signal.SIGTERM) + os.waitpid(p.pid, 0) + except OSError: + pass return ret with mock.patch(patch_target, side_effect=wrapper) as m: From adb444effecf6b19081fab592fa7f07335dd70a7 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 01:37:35 +0100 Subject: [PATCH 20/56] Timeout: use math.ceil to avoid truncation --- Lib/subprocess.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index b31750d23fc073..6d69869e30b616 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2080,7 +2080,7 @@ def _wait_pidfd(self, timeout): try: poller = select.poll() poller.register(pidfd, select.POLLIN) - events = poller.poll(int(timeout * 1000)) + events = poller.poll(math.ceil(timeout * 1000)) if not events: raise TimeoutExpired(self.args, timeout) return True From 4ec17c1e0b7f6a70fcf45a035c58f2766945ec40 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 01:38:28 +0100 Subject: [PATCH 21/56] Remove unused exception var --- Lib/subprocess.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 6d69869e30b616..0b946e10e088aa 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2106,7 +2106,7 @@ def _wait_kqueue(self, timeout): ) try: events = kq.control([kev], 1, timeout) # wait - except OSError as err: + except OSError: return False if not events: raise TimeoutExpired(self.args, timeout) From e807ba998fc10ffecacd37dc4760891cdcbc2110 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 01:40:33 +0100 Subject: [PATCH 22/56] Timeout: use math.ceil to avoid truncation --- Lib/subprocess.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 0b946e10e088aa..27b4b7b215e3c7 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -52,6 +52,7 @@ import warnings import contextlib import functools +import math from time import monotonic as _time import types @@ -2080,7 +2081,7 @@ def _wait_pidfd(self, timeout): try: poller = select.poll() poller.register(pidfd, select.POLLIN) - events = poller.poll(math.ceil(timeout * 1000)) + events = poller.poll(int(timeout * 1000)) if not events: raise TimeoutExpired(self.args, timeout) return True @@ -2106,7 +2107,7 @@ def _wait_kqueue(self, timeout): ) try: events = kq.control([kev], 1, timeout) # wait - except OSError: + except OSError as err: return False if not events: raise TimeoutExpired(self.args, timeout) From 1ddc52b09a3d175767adf715d3013eb6ad851989 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 01:45:53 +0100 Subject: [PATCH 23/56] Replace _can_use_kqueue() -> _CAN_USE_KQUEUE --- Lib/subprocess.py | 12 +++++------- Lib/test/test_subprocess.py | 6 +++--- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 27b4b7b215e3c7..f8bd0716b33408 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -749,18 +749,16 @@ def _use_posix_spawn(): # By default, assume that posix_spawn() does not properly report errors. return False - -@functools.lru_cache -def _can_use_kqueue(): - names = ( +_CAN_USE_KQUEUE = all( + hasattr(select, x) + for x in ( "kqueue", "KQ_EV_ADD", "KQ_EV_ONESHOT", "KQ_FILTER_PROC", "KQ_NOTE_EXIT", ) - return all(hasattr(select, x) for x in names) - +) # These are primarily fail-safe knobs for negatives. A True value does not # guarantee the given libc/syscall API will be used. @@ -2090,7 +2088,7 @@ def _wait_pidfd(self, timeout): def _wait_kqueue(self, timeout): """Wait for PID to terminate using kqueue(). macOS and BSD only.""" - if not _can_use_kqueue(): + if not _CAN_USE_KQUEUE: return False try: kq = select.kqueue() diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index 901f6289fe67f0..0dd75c9b6fcd44 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -4116,13 +4116,13 @@ def test_wait_pidfd_open_error(self): self.assert_fast_waitpid_error("os.pidfd_open") @unittest.skipIf( - not subprocess._can_use_kqueue(), reason="macOS / BSD only" + not subprocess._CAN_USE_KQUEUE, reason="macOS / BSD only" ) def test_wait_kqueue_error(self): self.assert_fast_waitpid_error("select.kqueue") @unittest.skipIf( - not subprocess._can_use_kqueue(), reason="macOS / BSD only" + not subprocess._CAN_USE_KQUEUE, reason="macOS / BSD only" ) def test_kqueue_control_error(self): # Emulate a case where kqueue.control() fails. _busy_wait() @@ -4167,7 +4167,7 @@ def test_pidfd_open_race(self): self.assert_wait_race_condition("os.pidfd_open", os.pidfd_open) @unittest.skipIf( - not subprocess._can_use_kqueue(), reason="macOS / BSD only" + not subprocess._CAN_USE_KQUEUE, reason="macOS / BSD only" ) def test_kqueue_race(self): self.assert_wait_race_condition("select.kqueue", select.kqueue) From 645ef6c9899a12d3e0cd5f7326388cafdc773ccc Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 01:48:08 +0100 Subject: [PATCH 24/56] Shorten code --- Lib/test/test_subprocess.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index 0dd75c9b6fcd44..e8de7f4afc9d0e 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -4098,6 +4098,8 @@ class FastWaitTestCase(BaseTestCase): """Tests for efficient (pidfd_open / kqueue) process waiting in subprocess.Popen.wait(). """ + CAN_USE_PIDFD_OPEN = hasattr(os, "pidfd_open") + CAN_USE_KQUEUE = subprocess._CAN_USE_KQUEUE def assert_fast_waitpid_error(self, patch_point): # Emulate a case where pidfd_open() (Linux) or kqueue() @@ -4111,19 +4113,15 @@ def assert_fast_waitpid_error(self, patch_point): self.assertEqual(p.wait(timeout=support.SHORT_TIMEOUT), 0) assert m.called - @unittest.skipIf(not hasattr(os, "pidfd_open"), reason="LINUX only") + @unittest.skipIf(not CAN_USE_PIDFD_OPEN, reason="LINUX only") def test_wait_pidfd_open_error(self): self.assert_fast_waitpid_error("os.pidfd_open") - @unittest.skipIf( - not subprocess._CAN_USE_KQUEUE, reason="macOS / BSD only" - ) + @unittest.skipIf(not CAN_USE_KQUEUE, reason="macOS / BSD only") def test_wait_kqueue_error(self): self.assert_fast_waitpid_error("select.kqueue") - @unittest.skipIf( - not subprocess._CAN_USE_KQUEUE, reason="macOS / BSD only" - ) + @unittest.skipIf(not CAN_USE_KQUEUE, reason="macOS / BSD only") def test_kqueue_control_error(self): # Emulate a case where kqueue.control() fails. _busy_wait() # should be used as fallback. @@ -4162,13 +4160,11 @@ def wrapper(*args, **kwargs): assert m.called self.assertEqual(status, 0) - @unittest.skipIf(not hasattr(os, "pidfd_open"), reason="LINUX only") + @unittest.skipIf(not CAN_USE_PIDFD_OPEN, reason="LINUX only") def test_pidfd_open_race(self): self.assert_wait_race_condition("os.pidfd_open", os.pidfd_open) - @unittest.skipIf( - not subprocess._CAN_USE_KQUEUE, reason="macOS / BSD only" - ) + @unittest.skipIf(not CAN_USE_KQUEUE, reason="macOS / BSD only") def test_kqueue_race(self): self.assert_wait_race_condition("select.kqueue", select.kqueue) From 6ee771b9921f26f6fe2d40d7e903822a6d8c6db4 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 02:24:44 +0100 Subject: [PATCH 25/56] Use waitpid() + WNOHANG even if process exited to avoid rare race --- Lib/subprocess.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index f8bd0716b33408..c586850ad6c342 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2157,7 +2157,13 @@ def _wait(self, timeout): if timeout < 0: raise TimeoutExpired(self.args, timeout) if self._wait_pidfd(timeout) or self._wait_kqueue(timeout): - self._blocking_wait() + # At this point os.waitpid(pid, 0) should return + # immediately, but in rare races another thread or + # signal handler may have already reaped the PID. + # Using _busy_wait(0) (WNOHANG) ensures we attempt + # a non-blocking reap safely without blocking + # indefinitely. + self._busy_wait(0) else: self._busy_wait(timeout) else: From 61c6b99bd2b9d88907baaaad5b6754c2ad81d813 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 02:58:32 +0100 Subject: [PATCH 26/56] Revert prev change --- Lib/subprocess.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index c586850ad6c342..f8bd0716b33408 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2157,13 +2157,7 @@ def _wait(self, timeout): if timeout < 0: raise TimeoutExpired(self.args, timeout) if self._wait_pidfd(timeout) or self._wait_kqueue(timeout): - # At this point os.waitpid(pid, 0) should return - # immediately, but in rare races another thread or - # signal handler may have already reaped the PID. - # Using _busy_wait(0) (WNOHANG) ensures we attempt - # a non-blocking reap safely without blocking - # indefinitely. - self._busy_wait(0) + self._blocking_wait() else: self._busy_wait(timeout) else: From 527646d8af626f23f2c4eb3eecdaceefe2899afe Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 03:02:10 +0100 Subject: [PATCH 27/56] Use ceil(timeout) to avoid truncation --- Lib/subprocess.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index f8bd0716b33408..712ea1408e207d 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -51,7 +51,6 @@ import threading import warnings import contextlib -import functools import math from time import monotonic as _time import types @@ -2079,7 +2078,7 @@ def _wait_pidfd(self, timeout): try: poller = select.poll() poller.register(pidfd, select.POLLIN) - events = poller.poll(int(timeout * 1000)) + events = poller.poll(math.ceil(timeout * 1000)) if not events: raise TimeoutExpired(self.args, timeout) return True From 0a8a1b2bb48a39371bab227b407dafe04ad7baa8 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 03:05:27 +0100 Subject: [PATCH 28/56] Remove check for timeout < 0 + rm test case which didn't make sense. --- Lib/subprocess.py | 3 +-- Lib/test/test_subprocess.py | 6 ------ 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 712ea1408e207d..769eb1c08394c9 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2153,8 +2153,7 @@ def _wait(self, timeout): return self.returncode if timeout is not None: - if timeout < 0: - raise TimeoutExpired(self.args, timeout) + # Try fast wait first (pidfd on Linux, kqueue on BSD/macOS). if self._wait_pidfd(timeout) or self._wait_kqueue(timeout): self._blocking_wait() else: diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index e8de7f4afc9d0e..af1d7f4d086c47 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -163,12 +163,6 @@ def test_call_timeout(self): timeout=0.1) def test_timeout_exception(self): - try: - subprocess.run([sys.executable, '-c', 'import time;time.sleep(9)'], timeout = -1) - except subprocess.TimeoutExpired as e: - self.assertIn("-1 seconds", str(e)) - else: - self.fail("Expected TimeoutExpired exception not raised") try: subprocess.run([sys.executable, '-c', 'import time;time.sleep(9)'], timeout = 0) except subprocess.TimeoutExpired as e: From 73b97dc089d56f2769e098d045f1fdc65e3854fe Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 03:09:44 +0100 Subject: [PATCH 29/56] Don't use _blocking_wait() as it has a while loop --- Lib/subprocess.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 769eb1c08394c9..b3512f15c9f3a6 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2135,18 +2135,6 @@ def _busy_wait(self, timeout): delay = min(delay * 2, remaining, .05) time.sleep(delay) - def _blocking_wait(self): - while self.returncode is None: - with self._waitpid_lock: - if self.returncode is not None: - break # Another thread waited. - (pid, sts) = self._try_wait(0) - # Check the pid and loop as waitpid has been known to - # return 0 even without WNOHANG in odd situations. - # http://bugs.python.org/issue14396. - if pid == self.pid: - self._handle_exitstatus(sts) - def _wait(self, timeout): """Internal implementation of wait() on POSIX.""" if self.returncode is not None: @@ -2155,11 +2143,27 @@ def _wait(self, timeout): if timeout is not None: # Try fast wait first (pidfd on Linux, kqueue on BSD/macOS). if self._wait_pidfd(timeout) or self._wait_kqueue(timeout): - self._blocking_wait() + with self._waitpid_lock: + if self.returncode is not None: + return self.returncode + pid, sts = self._try_wait(os.WNOHANG) + if pid == self.pid: + self._handle_exitstatus(sts) + return self.returncode else: self._busy_wait(timeout) else: - self._blocking_wait() + while self.returncode is None: + with self._waitpid_lock: + if self.returncode is not None: + break # Another thread waited. + (pid, sts) = self._try_wait(0) + # Check the pid and loop as waitpid has been known to + # return 0 even without WNOHANG in odd situations. + # http://bugs.python.org/issue14396. + if pid == self.pid: + self._handle_exitstatus(sts) + return self.returncode From 2d3c3f76f8608cf4cee8839184445a4d3514f632 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 03:11:48 +0100 Subject: [PATCH 30/56] Add docstring --- Lib/subprocess.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index b3512f15c9f3a6..262a7c3a068d1c 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2136,12 +2136,17 @@ def _busy_wait(self, timeout): time.sleep(delay) def _wait(self, timeout): - """Internal implementation of wait() on POSIX.""" + """Internal implementation of wait() on POSIX. + + Uses efficient pidfd_open() + poll() on Linux or kqueue() + on macOS/BSD when available. Falls back to polling + waitpid(WNOHANG) otherwise. + """ if self.returncode is not None: return self.returncode if timeout is not None: - # Try fast wait first (pidfd on Linux, kqueue on BSD/macOS). + # Try efficient wait first. if self._wait_pidfd(timeout) or self._wait_kqueue(timeout): with self._waitpid_lock: if self.returncode is not None: From 4eac42f39a4660205eb0cadb149daa16dc1da8de Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 03:23:32 +0100 Subject: [PATCH 31/56] Rm _busy_wait() --- Lib/subprocess.py | 48 +++++++++++++++++++++++------------------------ 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 262a7c3a068d1c..5a4e1906fb0d5f 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2112,29 +2112,6 @@ def _wait_kqueue(self, timeout): finally: kq.close() - def _busy_wait(self, timeout): - endtime = _time() + timeout - # Enter a busy loop if we have a timeout. This busy loop was - # cribbed from Lib/threading.py in Thread.wait() at r71065. - delay = 0.0005 # 500 us -> initial delay of 1 ms - while True: - if self._waitpid_lock.acquire(False): - try: - if self.returncode is not None: - break # Another thread waited. - (pid, sts) = self._try_wait(os.WNOHANG) - assert pid == self.pid or pid == 0 - if pid == self.pid: - self._handle_exitstatus(sts) - break - finally: - self._waitpid_lock.release() - remaining = self._remaining_time(endtime) - if remaining <= 0: - raise TimeoutExpired(self.args, timeout) - delay = min(delay * 2, remaining, .05) - time.sleep(delay) - def _wait(self, timeout): """Internal implementation of wait() on POSIX. @@ -2155,8 +2132,29 @@ def _wait(self, timeout): if pid == self.pid: self._handle_exitstatus(sts) return self.returncode - else: - self._busy_wait(timeout) + return None + + # Enter a busy loop if we have a timeout. This busy loop was + # cribbed from Lib/threading.py in Thread.wait() at r71065. + endtime = _time() + timeout + delay = 0.0005 # 500 us -> initial delay of 1 ms + while True: + if self._waitpid_lock.acquire(False): + try: + if self.returncode is not None: + break # Another thread waited. + (pid, sts) = self._try_wait(os.WNOHANG) + assert pid == self.pid or pid == 0 + if pid == self.pid: + self._handle_exitstatus(sts) + break + finally: + self._waitpid_lock.release() + remaining = self._remaining_time(endtime) + if remaining <= 0: + raise TimeoutExpired(self.args, timeout) + delay = min(delay * 2, remaining, .05) + time.sleep(delay) else: while self.returncode is None: with self._waitpid_lock: From 3c156a9d11ad0b232f7443314b74b092f056882a Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 03:26:25 +0100 Subject: [PATCH 32/56] Add comment --- Lib/subprocess.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 5a4e1906fb0d5f..f90ff5cc240edf 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2125,6 +2125,12 @@ def _wait(self, timeout): if timeout is not None: # Try efficient wait first. if self._wait_pidfd(timeout) or self._wait_kqueue(timeout): + # Process is gone. At this point os.waitpid(pid, 0) + # should return immediately, but in rare races + # another thread or signal handler may have already + # reaped the PID. os.waitpid(pid, WNOHANG) ensures + # we attempt a non-blocking reap safely without + # blocking indefinitely. with self._waitpid_lock: if self.returncode is not None: return self.returncode From dac7d3bdfeea438d001f9a7e30e533252d4ed07d Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 03:29:02 +0100 Subject: [PATCH 33/56] Add assert --- Lib/subprocess.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index f90ff5cc240edf..60e8ac41e40f12 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2134,7 +2134,8 @@ def _wait(self, timeout): with self._waitpid_lock: if self.returncode is not None: return self.returncode - pid, sts = self._try_wait(os.WNOHANG) + (pid, sts) = self._try_wait(os.WNOHANG) + assert pid == self.pid or pid == 0 if pid == self.pid: self._handle_exitstatus(sts) return self.returncode From 5c7ec2f56fdf4577a3e9fee73ce16f19f30883a8 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 03:35:40 +0100 Subject: [PATCH 34/56] Update comments --- Lib/subprocess.py | 3 +-- Lib/test/test_subprocess.py | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 60e8ac41e40f12..3f6530325a1a70 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2067,8 +2067,7 @@ def _wait_pidfd(self, timeout): pidfd = os.pidfd_open(self.pid, 0) except OSError: # May be: - # - ESRCH: no such process; waitpid() should still be - # able to return the status code. + # - ESRCH: no such process # - EMFILE, ENFILE: too many open files (usually 1024) # - ENODEV: anonymous inode filesystem not supported # - EPERM, EACCES, ENOSYS: undocumented; may happen if diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index af1d7f4d086c47..d8fb651347e8c7 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -4097,7 +4097,7 @@ class FastWaitTestCase(BaseTestCase): def assert_fast_waitpid_error(self, patch_point): # Emulate a case where pidfd_open() (Linux) or kqueue() - # (BSD/macOS) fails. _busy_wait() should be used as fallback. + # (BSD/macOS) fails. Busy-poll wait should be used as fallback. exc = OSError(errno.EMFILE, os.strerror(errno.EMFILE)) with mock.patch(patch_point, side_effect=exc) as m: p = subprocess.Popen([sys.executable, @@ -4117,7 +4117,7 @@ def test_wait_kqueue_error(self): @unittest.skipIf(not CAN_USE_KQUEUE, reason="macOS / BSD only") def test_kqueue_control_error(self): - # Emulate a case where kqueue.control() fails. _busy_wait() + # Emulate a case where kqueue.control() fails. Busy-poll wait # should be used as fallback. p = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(0.3)"]) From 5c29144c54c3b68a0a6f4d70b27309b839a27188 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 03:37:39 +0100 Subject: [PATCH 35/56] Add test for timeout=0 --- Lib/test/test_subprocess.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index d8fb651347e8c7..48d2b0746c6bd6 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -1417,6 +1417,8 @@ def test_wait(self): def test_wait_timeout(self): p = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(0.3)"]) + with self.assertRaises(subprocess.TimeoutExpired) as c: + p.wait(timeout=0) with self.assertRaises(subprocess.TimeoutExpired) as c: p.wait(timeout=0.0001) self.assertIn("0.0001", str(c.exception)) # For coverage of __str__. From 4359b07f0e9fa35a771d15403eddc44c1cb0659d Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 14:16:14 +0100 Subject: [PATCH 36/56] Update comment about PID reuse race --- Lib/subprocess.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 3f6530325a1a70..90ad6c50f600e3 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2125,11 +2125,10 @@ def _wait(self, timeout): # Try efficient wait first. if self._wait_pidfd(timeout) or self._wait_kqueue(timeout): # Process is gone. At this point os.waitpid(pid, 0) - # should return immediately, but in rare races - # another thread or signal handler may have already - # reaped the PID. os.waitpid(pid, WNOHANG) ensures - # we attempt a non-blocking reap safely without - # blocking indefinitely. + # should return immediately, but in rare races the + # PID may have been reused. + # os.waitpid(pid, WNOHANG) ensures we attempt a + # non-blocking reap without blocking indefinitely. with self._waitpid_lock: if self.returncode is not None: return self.returncode @@ -2137,8 +2136,7 @@ def _wait(self, timeout): assert pid == self.pid or pid == 0 if pid == self.pid: self._handle_exitstatus(sts) - return self.returncode - return None + return self.returncode # Enter a busy loop if we have a timeout. This busy loop was # cribbed from Lib/threading.py in Thread.wait() at r71065. From 81275c834d65bb3f64c3d1ed465f739e49556aad Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 14:21:26 +0100 Subject: [PATCH 37/56] Update comment --- Lib/subprocess.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 90ad6c50f600e3..2c55fa7b44a1f3 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2125,13 +2125,13 @@ def _wait(self, timeout): # Try efficient wait first. if self._wait_pidfd(timeout) or self._wait_kqueue(timeout): # Process is gone. At this point os.waitpid(pid, 0) - # should return immediately, but in rare races the - # PID may have been reused. + # will return immediately, but in very rare races + # the PID may have been reused. # os.waitpid(pid, WNOHANG) ensures we attempt a # non-blocking reap without blocking indefinitely. with self._waitpid_lock: if self.returncode is not None: - return self.returncode + return self.returncode # Another thread waited. (pid, sts) = self._try_wait(os.WNOHANG) assert pid == self.pid or pid == 0 if pid == self.pid: From 43b500f56c06208654e013bae581a417be35e582 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 16:07:39 +0100 Subject: [PATCH 38/56] Handle rare case where poll() says we're done, but waitpid() doesn't --- Lib/subprocess.py | 13 +++++++++++-- Lib/test/test_subprocess.py | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 2c55fa7b44a1f3..1b64e89c0575e3 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2122,6 +2122,9 @@ def _wait(self, timeout): return self.returncode if timeout is not None: + started = _time() + endtime = started + timeout + # Try efficient wait first. if self._wait_pidfd(timeout) or self._wait_kqueue(timeout): # Process is gone. At this point os.waitpid(pid, 0) @@ -2136,11 +2139,17 @@ def _wait(self, timeout): assert pid == self.pid or pid == 0 if pid == self.pid: self._handle_exitstatus(sts) - return self.returncode + return self.returncode + # os.waitpid(pid, WNOHANG) returned 0 instead + # of our PID, meaning PID has not yet exited, + # even though poll() / kqueue() said so. Very + # rare and mostly theoretical. Fallback to busy + # polling. + elapsed = _time() - started + endtime -= elapsed # Enter a busy loop if we have a timeout. This busy loop was # cribbed from Lib/threading.py in Thread.wait() at r71065. - endtime = _time() + timeout delay = 0.0005 # 500 us -> initial delay of 1 ms while True: if self._waitpid_lock.acquire(False): diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index 48d2b0746c6bd6..a552951bf6be60 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -4091,8 +4091,8 @@ def test_broken_pipe_cleanup(self): class FastWaitTestCase(BaseTestCase): - """Tests for efficient (pidfd_open / kqueue) process waiting in - subprocess.Popen.wait(). + """Tests for efficient (pidfd_open() + poll() / kqueue()) process + waiting in subprocess.Popen.wait(). """ CAN_USE_PIDFD_OPEN = hasattr(os, "pidfd_open") CAN_USE_KQUEUE = subprocess._CAN_USE_KQUEUE @@ -4164,6 +4164,36 @@ def test_pidfd_open_race(self): def test_kqueue_race(self): self.assert_wait_race_condition("select.kqueue", select.kqueue) + def assert_notification_without_immediate_reap(self, patch_target): + # Verify fallback to busy polling when poll() / kqueue() + # succeeds, but waitpid(pid, WNOHANG) returns (0, 0). + def waitpid_wrapper(pid, flags): + nonlocal ncalls + ncalls += 1 + if ncalls == 1: + return (0, 0) + return real_waitpid(pid, flags) + + ncalls = 0 + real_waitpid = os.waitpid + with mock.patch.object(subprocess.Popen, patch_target, return_value=True) as m1: + with mock.patch("os.waitpid", side_effect=waitpid_wrapper) as m2: + p = subprocess.Popen([sys.executable, + "-c", "import time; time.sleep(0.3)"]) + with self.assertRaises(subprocess.TimeoutExpired): + p.wait(timeout=0.0001) + self.assertEqual(p.wait(timeout=support.SHORT_TIMEOUT), 0) + assert m1.called + assert m2.called + + @unittest.skipIf(not CAN_USE_PIDFD_OPEN, reason="LINUX only") + def test_poll_notification_without_immediate_reap(self): + self.assert_notification_without_immediate_reap("_wait_pidfd") + + @unittest.skipIf(not CAN_USE_KQUEUE, reason="macOS / BSD only") + def test_kqueue_notification_without_immediate_reap(self): + self.assert_notification_without_immediate_reap("_wait_kqueue") + if __name__ == "__main__": unittest.main() From b64e42b135fcb6e77cf92cee6a8e0a5d629f2ce7 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 16:42:06 +0100 Subject: [PATCH 39/56] Update Doc/library/subprocess.rst --- Doc/library/subprocess.rst | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Doc/library/subprocess.rst b/Doc/library/subprocess.rst index b8dfcc310771fe..e189d705ba34d0 100644 --- a/Doc/library/subprocess.rst +++ b/Doc/library/subprocess.rst @@ -803,14 +803,24 @@ Instances of the :class:`Popen` class have the following methods: .. note:: - When the ``timeout`` parameter is not ``None``, then (on POSIX) the - function is implemented using a busy loop (non-blocking call and short - sleeps). Use the :mod:`asyncio` module for an asynchronous wait: see + When ``timeout`` is not ``None``, an efficient event-driven mechanism + waits for process termination when available: + + - Linux ≥5.3 uses :func:`os.pidfd_open` + :func:`select.poll` + - macOS and other BSD variants use :func:`select.kqueue` + - Windows uses ``WaitForSingleObject`` + + If none of these mechanisms are available, the function falls back to a + busy loop (non-blocking call and short sleeps). Use the :mod:`asyncio` + module for an asynchronous wait: see :class:`asyncio.create_subprocess_exec`. .. versionchanged:: 3.3 *timeout* was added. + .. versionchanged:: 3.15 + use efficient event-driven implementation on Linux >= 5.3 and macOS / BSD. + .. method:: Popen.communicate(input=None, timeout=None) Interact with process: Send data to stdin. Read data from stdout and stderr, From a1014063f11d7c9f39b753b8a2ca40549f91b70c Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 16:59:44 +0100 Subject: [PATCH 40/56] Add news entry --- Doc/library/subprocess.rst | 6 ++++-- .../2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst | 12 ++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst diff --git a/Doc/library/subprocess.rst b/Doc/library/subprocess.rst index e189d705ba34d0..3e2ba2a3b7ec00 100644 --- a/Doc/library/subprocess.rst +++ b/Doc/library/subprocess.rst @@ -807,7 +807,8 @@ Instances of the :class:`Popen` class have the following methods: waits for process termination when available: - Linux ≥5.3 uses :func:`os.pidfd_open` + :func:`select.poll` - - macOS and other BSD variants use :func:`select.kqueue` + - macOS and other BSD variants use :func:`select.kqueue` + + ``KQ_FILTER_PROC`` + ``KQ_NOTE_EXIT`` - Windows uses ``WaitForSingleObject`` If none of these mechanisms are available, the function falls back to a @@ -819,7 +820,8 @@ Instances of the :class:`Popen` class have the following methods: *timeout* was added. .. versionchanged:: 3.15 - use efficient event-driven implementation on Linux >= 5.3 and macOS / BSD. + if *timeout* is not ``None``, use efficient event-driven implementation + on Linux >= 5.3 and macOS / BSD. .. method:: Popen.communicate(input=None, timeout=None) diff --git a/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst b/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst new file mode 100644 index 00000000000000..af02ce5105b076 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst @@ -0,0 +1,12 @@ +:meth:`subprocess.Popen.wait`: when ``timeout`` is not ``None``, an efficient +event-driven mechanism now waits for process termination, if available: + +- Linux ≥5.3 uses :func:`os.pidfd_open` + :func:`select.poll` +- macOS and other BSD variants use :func:`select.kqueue` + ``KQ_FILTER_PROC`` + + ``KQ_NOTE_EXIT`` +- Windows keep using ``WaitForSingleObject`` (unchanged) + +If none of these mechanisms are available, the function falls back to the +traditional busy loop (non-blocking call and short sleeps). + +Patch by Giampaolo Rodola. From 452f8c4654089edc200ff90063c6fb8be82992f6 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 17:06:21 +0100 Subject: [PATCH 41/56] Add entry in Doc/whatsnew/3.15.rst --- Doc/whatsnew/3.15.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index b7a27d5db63875..013bb8ae195b9c 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -736,6 +736,20 @@ ssl (Contributed by Ron Frederick in :gh:`138252`.) +subprocess +---------- + +* :meth:`subprocess.Popen.wait`: when ``timeout`` is not ``None``, an efficient + event-driven mechanism now waits for process termination, if available: + + - Linux ≥5.3 uses :func:`os.pidfd_open` + :func:`select.poll` + - macOS and other BSD variants use :func:`select.kqueue` + ``KQ_FILTER_PROC`` + + ``KQ_NOTE_EXIT`` + - Windows keep using ``WaitForSingleObject`` (unchanged) + + If none of these mechanisms are available, the function falls back to the + traditional busy loop (non-blocking call and short sleeps). + (Contributed by Giampaolo Rodola in :gh:`83069`). sys --- From b0c9890095962fb6ecd46a54a824344a3fe293e3 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 17:08:51 +0100 Subject: [PATCH 42/56] Fix typo --- Doc/library/subprocess.rst | 2 +- Doc/whatsnew/3.15.rst | 2 +- .../next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Doc/library/subprocess.rst b/Doc/library/subprocess.rst index 3e2ba2a3b7ec00..c136544395c11c 100644 --- a/Doc/library/subprocess.rst +++ b/Doc/library/subprocess.rst @@ -806,7 +806,7 @@ Instances of the :class:`Popen` class have the following methods: When ``timeout`` is not ``None``, an efficient event-driven mechanism waits for process termination when available: - - Linux ≥5.3 uses :func:`os.pidfd_open` + :func:`select.poll` + - Linux ≥= 5.3 uses :func:`os.pidfd_open` + :func:`select.poll` - macOS and other BSD variants use :func:`select.kqueue` + ``KQ_FILTER_PROC`` + ``KQ_NOTE_EXIT`` - Windows uses ``WaitForSingleObject`` diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 013bb8ae195b9c..995980aa0fa670 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -742,7 +742,7 @@ subprocess * :meth:`subprocess.Popen.wait`: when ``timeout`` is not ``None``, an efficient event-driven mechanism now waits for process termination, if available: - - Linux ≥5.3 uses :func:`os.pidfd_open` + :func:`select.poll` + - Linux ≥= 5.3 uses :func:`os.pidfd_open` + :func:`select.poll` - macOS and other BSD variants use :func:`select.kqueue` + ``KQ_FILTER_PROC`` + ``KQ_NOTE_EXIT`` - Windows keep using ``WaitForSingleObject`` (unchanged) diff --git a/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst b/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst index af02ce5105b076..559abd4ee29303 100644 --- a/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst +++ b/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst @@ -1,7 +1,7 @@ :meth:`subprocess.Popen.wait`: when ``timeout`` is not ``None``, an efficient event-driven mechanism now waits for process termination, if available: -- Linux ≥5.3 uses :func:`os.pidfd_open` + :func:`select.poll` +- Linux ≥= 5.3 uses :func:`os.pidfd_open` + :func:`select.poll` - macOS and other BSD variants use :func:`select.kqueue` + ``KQ_FILTER_PROC`` + ``KQ_NOTE_EXIT`` - Windows keep using ``WaitForSingleObject`` (unchanged) From 27b7c9f4da04ffccbee0e19c098bcd1470fac858 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 17:25:04 +0100 Subject: [PATCH 43/56] Re-wording --- Doc/library/subprocess.rst | 11 +++++++---- Doc/whatsnew/3.15.rst | 7 ++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/Doc/library/subprocess.rst b/Doc/library/subprocess.rst index c136544395c11c..3b3cb7b946c23a 100644 --- a/Doc/library/subprocess.rst +++ b/Doc/library/subprocess.rst @@ -803,8 +803,8 @@ Instances of the :class:`Popen` class have the following methods: .. note:: - When ``timeout`` is not ``None``, an efficient event-driven mechanism - waits for process termination when available: + When ``timeout`` is not ``None`` and the platform supports it, an + efficient event-driven mechanism is used to wait for process termination: - Linux ≥= 5.3 uses :func:`os.pidfd_open` + :func:`select.poll` - macOS and other BSD variants use :func:`select.kqueue` + @@ -812,8 +812,11 @@ Instances of the :class:`Popen` class have the following methods: - Windows uses ``WaitForSingleObject`` If none of these mechanisms are available, the function falls back to a - busy loop (non-blocking call and short sleeps). Use the :mod:`asyncio` - module for an asynchronous wait: see + busy loop (non-blocking call and short sleeps). + + .. note:: + + Use the :mod:`asyncio` module for an asynchronous wait: see :class:`asyncio.create_subprocess_exec`. .. versionchanged:: 3.3 diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 995980aa0fa670..96611d158a7967 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -739,13 +739,14 @@ ssl subprocess ---------- -* :meth:`subprocess.Popen.wait`: when ``timeout`` is not ``None``, an efficient - event-driven mechanism now waits for process termination, if available: +* :meth:`subprocess.Popen.wait`: when ``timeout`` is not ``None`` and the + platform supports it, an efficient event-driven mechanism is used to wait for + process termination: - Linux ≥= 5.3 uses :func:`os.pidfd_open` + :func:`select.poll` - macOS and other BSD variants use :func:`select.kqueue` + ``KQ_FILTER_PROC`` + ``KQ_NOTE_EXIT`` - - Windows keep using ``WaitForSingleObject`` (unchanged) + - Windows keeps using ``WaitForSingleObject`` (unchanged) If none of these mechanisms are available, the function falls back to the traditional busy loop (non-blocking call and short sleeps). From e1da99625c731623bb6ad35911a6a4533dde4f29 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 17:30:04 +0100 Subject: [PATCH 44/56] Raise on timeout < 0 and re-add test case --- Lib/subprocess.py | 2 ++ Lib/test/test_subprocess.py | 6 ++++++ .../Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 1b64e89c0575e3..9c9d33833d7178 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2122,6 +2122,8 @@ def _wait(self, timeout): return self.returncode if timeout is not None: + if timeout < 0: + raise TimeoutExpired(self.args, timeout) started = _time() endtime = started + timeout diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index a552951bf6be60..ed082b0af6c17f 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -163,6 +163,12 @@ def test_call_timeout(self): timeout=0.1) def test_timeout_exception(self): + try: + subprocess.run([sys.executable, '-c', 'import time;time.sleep(9)'], timeout = -1) + except subprocess.TimeoutExpired as e: + self.assertIn("-1 seconds", str(e)) + else: + self.fail("Expected TimeoutExpired exception not raised") try: subprocess.run([sys.executable, '-c', 'import time;time.sleep(9)'], timeout = 0) except subprocess.TimeoutExpired as e: diff --git a/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst b/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst index 559abd4ee29303..b3618c5a0409f5 100644 --- a/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst +++ b/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst @@ -4,7 +4,7 @@ event-driven mechanism now waits for process termination, if available: - Linux ≥= 5.3 uses :func:`os.pidfd_open` + :func:`select.poll` - macOS and other BSD variants use :func:`select.kqueue` + ``KQ_FILTER_PROC`` + ``KQ_NOTE_EXIT`` -- Windows keep using ``WaitForSingleObject`` (unchanged) +- Windows keeps using ``WaitForSingleObject`` (unchanged) If none of these mechanisms are available, the function falls back to the traditional busy loop (non-blocking call and short sleeps). From 5c78acca3c89d338a5d614ee2890a2b411e0932a Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 17:43:34 +0100 Subject: [PATCH 45/56] Check if can really use can_use_pidfd() in unit tests --- Lib/test/test_subprocess.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index ed082b0af6c17f..e7cd8aedd8c955 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -4096,11 +4096,28 @@ def test_broken_pipe_cleanup(self): self.assertTrue(proc.stdin.closed) +# --- + +def can_use_pidfd(): + # Availability: Linux >= 5.3 + if not hasattr(os, "pidfd_open"): + return False + try: + pidfd = os.pidfd_open(os.getpid(), 0) + except OSError as err: + # blocked by security policy like SECCOMP + return False + else: + os.close(pidfd) + return True + + + class FastWaitTestCase(BaseTestCase): """Tests for efficient (pidfd_open() + poll() / kqueue()) process waiting in subprocess.Popen.wait(). """ - CAN_USE_PIDFD_OPEN = hasattr(os, "pidfd_open") + CAN_USE_PIDFD_OPEN = can_use_pidfd() CAN_USE_KQUEUE = subprocess._CAN_USE_KQUEUE def assert_fast_waitpid_error(self, patch_point): @@ -4115,7 +4132,7 @@ def assert_fast_waitpid_error(self, patch_point): self.assertEqual(p.wait(timeout=support.SHORT_TIMEOUT), 0) assert m.called - @unittest.skipIf(not CAN_USE_PIDFD_OPEN, reason="LINUX only") + @unittest.skipIf(not CAN_USE_PIDFD_OPEN, reason="pidfd_open not supported") def test_wait_pidfd_open_error(self): self.assert_fast_waitpid_error("os.pidfd_open") @@ -4162,7 +4179,7 @@ def wrapper(*args, **kwargs): assert m.called self.assertEqual(status, 0) - @unittest.skipIf(not CAN_USE_PIDFD_OPEN, reason="LINUX only") + @unittest.skipIf(not CAN_USE_PIDFD_OPEN, reason="pidfd_open not supported") def test_pidfd_open_race(self): self.assert_wait_race_condition("os.pidfd_open", os.pidfd_open) @@ -4192,8 +4209,8 @@ def waitpid_wrapper(pid, flags): assert m1.called assert m2.called - @unittest.skipIf(not CAN_USE_PIDFD_OPEN, reason="LINUX only") - def test_poll_notification_without_immediate_reap(self): + @unittest.skipIf(not CAN_USE_PIDFD_OPEN, reason="pidfd_open not supported") + def test_pidfd_open_notification_without_immediate_reap(self): self.assert_notification_without_immediate_reap("_wait_pidfd") @unittest.skipIf(not CAN_USE_KQUEUE, reason="macOS / BSD only") From df0538ae26073b770b91b696faa0312c2c798ced Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 17:58:16 +0100 Subject: [PATCH 46/56] Check if can really use kqueue() in unit tests --- Lib/test/test_subprocess.py | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index e7cd8aedd8c955..a7ed6d6ace4514 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -4112,13 +4112,32 @@ def can_use_pidfd(): return True +def can_use_kevent(): + if not subprocess._CAN_USE_KQUEUE: + return False + kq = select.kqueue() + try: + kev = select.kevent( + os.getpid(), + filter=select.KQ_FILTER_PROC, + flags=select.KQ_EV_ADD | select.KQ_EV_ONESHOT, + fflags=select.KQ_NOTE_EXIT, + ) + events = kq.control([kev], 1, 0) + return True + except OSError: + return False + finally: + kq.close() + + class FastWaitTestCase(BaseTestCase): """Tests for efficient (pidfd_open() + poll() / kqueue()) process waiting in subprocess.Popen.wait(). """ CAN_USE_PIDFD_OPEN = can_use_pidfd() - CAN_USE_KQUEUE = subprocess._CAN_USE_KQUEUE + CAN_USE_KQUEUE = can_use_kevent() def assert_fast_waitpid_error(self, patch_point): # Emulate a case where pidfd_open() (Linux) or kqueue() @@ -4132,15 +4151,15 @@ def assert_fast_waitpid_error(self, patch_point): self.assertEqual(p.wait(timeout=support.SHORT_TIMEOUT), 0) assert m.called - @unittest.skipIf(not CAN_USE_PIDFD_OPEN, reason="pidfd_open not supported") + @unittest.skipIf(not CAN_USE_PIDFD_OPEN, reason="needs pidfd_open()") def test_wait_pidfd_open_error(self): self.assert_fast_waitpid_error("os.pidfd_open") - @unittest.skipIf(not CAN_USE_KQUEUE, reason="macOS / BSD only") + @unittest.skipIf(not CAN_USE_KQUEUE, reason="needs kqueue() for proc") def test_wait_kqueue_error(self): self.assert_fast_waitpid_error("select.kqueue") - @unittest.skipIf(not CAN_USE_KQUEUE, reason="macOS / BSD only") + @unittest.skipIf(not CAN_USE_KQUEUE, reason="needs kqueue() for proc") def test_kqueue_control_error(self): # Emulate a case where kqueue.control() fails. Busy-poll wait # should be used as fallback. @@ -4179,11 +4198,11 @@ def wrapper(*args, **kwargs): assert m.called self.assertEqual(status, 0) - @unittest.skipIf(not CAN_USE_PIDFD_OPEN, reason="pidfd_open not supported") + @unittest.skipIf(not CAN_USE_PIDFD_OPEN, reason="needs pidfd_open()") def test_pidfd_open_race(self): self.assert_wait_race_condition("os.pidfd_open", os.pidfd_open) - @unittest.skipIf(not CAN_USE_KQUEUE, reason="macOS / BSD only") + @unittest.skipIf(not CAN_USE_KQUEUE, reason="needs kqueue() for proc") def test_kqueue_race(self): self.assert_wait_race_condition("select.kqueue", select.kqueue) @@ -4209,11 +4228,11 @@ def waitpid_wrapper(pid, flags): assert m1.called assert m2.called - @unittest.skipIf(not CAN_USE_PIDFD_OPEN, reason="pidfd_open not supported") + @unittest.skipIf(not CAN_USE_PIDFD_OPEN, reason="needs pidfd_open()") def test_pidfd_open_notification_without_immediate_reap(self): self.assert_notification_without_immediate_reap("_wait_pidfd") - @unittest.skipIf(not CAN_USE_KQUEUE, reason="macOS / BSD only") + @unittest.skipIf(not CAN_USE_KQUEUE, reason="needs kqueue() for proc") def test_kqueue_notification_without_immediate_reap(self): self.assert_notification_without_immediate_reap("_wait_kqueue") From 6d8e36cb8ae699383daaee4d6c8288f6a95379c4 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 18:30:15 +0100 Subject: [PATCH 47/56] Pre-emptively check whether to use the fast way methods --- Lib/subprocess.py | 73 ++++++++++++++++++++++++++++++------- Lib/test/test_subprocess.py | 39 +------------------- 2 files changed, 62 insertions(+), 50 deletions(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 9c9d33833d7178..866e1b6037603c 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -748,16 +748,63 @@ def _use_posix_spawn(): # By default, assume that posix_spawn() does not properly report errors. return False -_CAN_USE_KQUEUE = all( - hasattr(select, x) - for x in ( - "kqueue", - "KQ_EV_ADD", - "KQ_EV_ONESHOT", - "KQ_FILTER_PROC", - "KQ_NOTE_EXIT", - ) -) + +def _can_use_pidfd_open(): + # Availability: Linux >= 5.3 + if not hasattr(os, "pidfd_open"): + return False + try: + pidfd = os.pidfd_open(os.getpid(), 0) + except OSError as err: + if err.errno in {errno.EMFILE, errno.ENFILE}: + # transitory 'too many open files' + return True + # likely blocked by security policy like SECCOMP (EPERM, + # EACCES, ENOSYS) + return False + else: + os.close(pidfd) + return True + + +def _can_use_kqueue(): + # Availability: macOS, BSD + if not all( + hasattr(select, x) + for x in ( + "kqueue", + "KQ_EV_ADD", + "KQ_EV_ONESHOT", + "KQ_FILTER_PROC", + "KQ_NOTE_EXIT", + ) + ): + return False + + kq = None + try: + kq = select.kqueue() + kev = select.kevent( + os.getpid(), + filter=select.KQ_FILTER_PROC, + flags=select.KQ_EV_ADD | select.KQ_EV_ONESHOT, + fflags=select.KQ_NOTE_EXIT, + ) + events = kq.control([kev], 1, 0) + return True + except OSError as err: + if err.errno in {errno.EMFILE, errno.ENFILE}: + # transitory 'too many open files' + return True + return False + finally: + if kq is not None: + kq.close() + + +_CAN_USE_PIDFD_OPEN = _can_use_pidfd_open() +_CAN_USE_KQUEUE = _can_use_kqueue() + # These are primarily fail-safe knobs for negatives. A True value does not # guarantee the given libc/syscall API will be used. @@ -2061,7 +2108,7 @@ def _wait_pidfd(self, timeout): """Wait for PID to terminate using pidfd_open() + poll(). Linux >= 5.3 only. """ - if not hasattr(os, "pidfd_open"): + if not _CAN_USE_PIDFD_OPEN: return False try: pidfd = os.pidfd_open(self.pid, 0) @@ -2091,7 +2138,7 @@ def _wait_kqueue(self, timeout): try: kq = select.kqueue() except OSError: - # usually EMFILE / ENFILE (too many open files) + # likely EMFILE / ENFILE (too many open files) return False try: @@ -2103,7 +2150,7 @@ def _wait_kqueue(self, timeout): ) try: events = kq.control([kev], 1, timeout) # wait - except OSError as err: + except OSError as err: # should never happen return False if not events: raise TimeoutExpired(self.args, timeout) diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index a7ed6d6ace4514..f8e20fcc038d0c 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -4096,48 +4096,13 @@ def test_broken_pipe_cleanup(self): self.assertTrue(proc.stdin.closed) -# --- - -def can_use_pidfd(): - # Availability: Linux >= 5.3 - if not hasattr(os, "pidfd_open"): - return False - try: - pidfd = os.pidfd_open(os.getpid(), 0) - except OSError as err: - # blocked by security policy like SECCOMP - return False - else: - os.close(pidfd) - return True - - -def can_use_kevent(): - if not subprocess._CAN_USE_KQUEUE: - return False - kq = select.kqueue() - try: - kev = select.kevent( - os.getpid(), - filter=select.KQ_FILTER_PROC, - flags=select.KQ_EV_ADD | select.KQ_EV_ONESHOT, - fflags=select.KQ_NOTE_EXIT, - ) - events = kq.control([kev], 1, 0) - return True - except OSError: - return False - finally: - kq.close() - - class FastWaitTestCase(BaseTestCase): """Tests for efficient (pidfd_open() + poll() / kqueue()) process waiting in subprocess.Popen.wait(). """ - CAN_USE_PIDFD_OPEN = can_use_pidfd() - CAN_USE_KQUEUE = can_use_kevent() + CAN_USE_PIDFD_OPEN = subprocess._CAN_USE_PIDFD_OPEN + CAN_USE_KQUEUE = subprocess._CAN_USE_KQUEUE def assert_fast_waitpid_error(self, patch_point): # Emulate a case where pidfd_open() (Linux) or kqueue() From 6ba746504bedec09abf64c0e7e1b1e7489ac33da Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 18:44:22 +0100 Subject: [PATCH 48/56] Add test_fast_path_avoid_busy_loop --- Lib/test/test_subprocess.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index f8e20fcc038d0c..cfd4fea7aa79b1 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -4201,6 +4201,20 @@ def test_pidfd_open_notification_without_immediate_reap(self): def test_kqueue_notification_without_immediate_reap(self): self.assert_notification_without_immediate_reap("_wait_kqueue") + @unittest.skipUnless( + CAN_USE_PIDFD_OPEN or CAN_USE_KQUEUE, + "fast wait mechanism not available" + ) + def test_fast_path_avoid_busy_loop(self): + # assert that the busy loop is not called as long as the fast + # wait is available + with mock.patch('time.sleep') as m: + p = subprocess.Popen([sys.executable, + "-c", "import time; time.sleep(0.3)"]) + with self.assertRaises(subprocess.TimeoutExpired): + p.wait(timeout=0.0001) + self.assertEqual(p.wait(timeout=support.LONG_TIMEOUT), 0) + assert not m.called if __name__ == "__main__": unittest.main() From e3c7977298ffe07314bb4751c89027d6357b3c25 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 18:56:35 +0100 Subject: [PATCH 49/56] Update comments --- Lib/subprocess.py | 9 +++++---- Lib/test/test_subprocess.py | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 866e1b6037603c..f339060385a61e 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2152,8 +2152,9 @@ def _wait_kqueue(self, timeout): events = kq.control([kev], 1, timeout) # wait except OSError as err: # should never happen return False - if not events: - raise TimeoutExpired(self.args, timeout) + else: + if not events: + raise TimeoutExpired(self.args, timeout) return True finally: kq.close() @@ -2177,8 +2178,8 @@ def _wait(self, timeout): # Try efficient wait first. if self._wait_pidfd(timeout) or self._wait_kqueue(timeout): # Process is gone. At this point os.waitpid(pid, 0) - # will return immediately, but in very rare races - # the PID may have been reused. + # will return immediately, but in rare races (e.g. + # long running processes) the PID may have been reused. # os.waitpid(pid, WNOHANG) ensures we attempt a # non-blocking reap without blocking indefinitely. with self._waitpid_lock: diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index cfd4fea7aa79b1..cbf909a6e20636 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -4105,8 +4105,8 @@ class FastWaitTestCase(BaseTestCase): CAN_USE_KQUEUE = subprocess._CAN_USE_KQUEUE def assert_fast_waitpid_error(self, patch_point): - # Emulate a case where pidfd_open() (Linux) or kqueue() - # (BSD/macOS) fails. Busy-poll wait should be used as fallback. + # Emulate a case where pidfd_open() or kqueue() fails. + # Busy-poll wait should be used as fallback. exc = OSError(errno.EMFILE, os.strerror(errno.EMFILE)) with mock.patch(patch_point, side_effect=exc) as m: p = subprocess.Popen([sys.executable, From 97cc3be758ee8287399b35a07870a9f6f5d040ad Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 19:26:55 +0100 Subject: [PATCH 50/56] Fix missing import on Windows --- Lib/subprocess.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index f339060385a61e..38286d65041fc3 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -52,6 +52,7 @@ import warnings import contextlib import math +import select from time import monotonic as _time import types @@ -122,7 +123,6 @@ class _del_safe: WNOHANG = None ECHILD = errno.ECHILD - import select import selectors From 5d78d24bd4ee85aca76e6b94c3d7b7280df80487 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 20:18:55 +0100 Subject: [PATCH 51/56] Try to fix doc build error --- Doc/whatsnew/3.15.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 96611d158a7967..61f5abcc1e3413 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -744,8 +744,8 @@ subprocess process termination: - Linux ≥= 5.3 uses :func:`os.pidfd_open` + :func:`select.poll` - - macOS and other BSD variants use :func:`select.kqueue` + ``KQ_FILTER_PROC`` - + ``KQ_NOTE_EXIT`` + - macOS and other BSD variants use :func:`select.kqueue` + + ``KQ_FILTER_PROC`` + ``KQ_NOTE_EXIT`` - Windows keeps using ``WaitForSingleObject`` (unchanged) If none of these mechanisms are available, the function falls back to the From a916da39e011fe51e53bed4ad7db578949e21b95 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 20:28:33 +0100 Subject: [PATCH 52/56] Try to fix doc build error 2 --- Doc/whatsnew/3.15.rst | 3 +-- .../next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 61f5abcc1e3413..9fd681f704ee8a 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -744,8 +744,7 @@ subprocess process termination: - Linux ≥= 5.3 uses :func:`os.pidfd_open` + :func:`select.poll` - - macOS and other BSD variants use :func:`select.kqueue` + - ``KQ_FILTER_PROC`` + ``KQ_NOTE_EXIT`` + - macOS and other BSD variants use :func:`select.kqueue` + ``KQ_FILTER_PROC`` + ``KQ_NOTE_EXIT`` - Windows keeps using ``WaitForSingleObject`` (unchanged) If none of these mechanisms are available, the function falls back to the diff --git a/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst b/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst index b3618c5a0409f5..c9edb4c4a10188 100644 --- a/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst +++ b/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst @@ -2,8 +2,7 @@ event-driven mechanism now waits for process termination, if available: - Linux ≥= 5.3 uses :func:`os.pidfd_open` + :func:`select.poll` -- macOS and other BSD variants use :func:`select.kqueue` + ``KQ_FILTER_PROC`` + - ``KQ_NOTE_EXIT`` +- macOS and other BSD variants use :func:`select.kqueue` + ``KQ_FILTER_PROC`` +``KQ_NOTE_EXIT`` - Windows keeps using ``WaitForSingleObject`` (unchanged) If none of these mechanisms are available, the function falls back to the From 86200bdfbbf9526f3f5a9caaa06bcfce75933237 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 20:36:43 +0100 Subject: [PATCH 53/56] Try to fix doc build error 3 --- Lib/subprocess.py | 4 ++-- .../Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 38286d65041fc3..cbc1c686e092e0 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2178,8 +2178,8 @@ def _wait(self, timeout): # Try efficient wait first. if self._wait_pidfd(timeout) or self._wait_kqueue(timeout): # Process is gone. At this point os.waitpid(pid, 0) - # will return immediately, but in rare races (e.g. - # long running processes) the PID may have been reused. + # will return immediately, but in rare races the + # PID may have been reused. # os.waitpid(pid, WNOHANG) ensures we attempt a # non-blocking reap without blocking indefinitely. with self._waitpid_lock: diff --git a/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst b/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst index c9edb4c4a10188..98b43b17c01b37 100644 --- a/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst +++ b/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst @@ -2,7 +2,7 @@ event-driven mechanism now waits for process termination, if available: - Linux ≥= 5.3 uses :func:`os.pidfd_open` + :func:`select.poll` -- macOS and other BSD variants use :func:`select.kqueue` + ``KQ_FILTER_PROC`` +``KQ_NOTE_EXIT`` +- macOS and other BSD variants use :func:`select.kqueue` + ``KQ_FILTER_PROC`` + ``KQ_NOTE_EXIT`` - Windows keeps using ``WaitForSingleObject`` (unchanged) If none of these mechanisms are available, the function falls back to the From 85c38bcfee85dea8df018e10069fc29638d3c6c2 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 21:30:11 +0100 Subject: [PATCH 54/56] Try to fix doc build error 4 --- .../next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst b/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst index 98b43b17c01b37..fa228688904b35 100644 --- a/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst +++ b/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst @@ -2,7 +2,7 @@ event-driven mechanism now waits for process termination, if available: - Linux ≥= 5.3 uses :func:`os.pidfd_open` + :func:`select.poll` -- macOS and other BSD variants use :func:`select.kqueue` + ``KQ_FILTER_PROC`` + ``KQ_NOTE_EXIT`` +- macOS and other BSD variants use :func:`select.kqueue` - Windows keeps using ``WaitForSingleObject`` (unchanged) If none of these mechanisms are available, the function falls back to the From 3c92c1d1fa4af51763bf41f57e1e2c027f768fad Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 21:32:56 +0100 Subject: [PATCH 55/56] Try to fix doc build error 5 --- ...2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst b/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst index fa228688904b35..ebe5e93abba130 100644 --- a/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst +++ b/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst @@ -1,11 +1,7 @@ :meth:`subprocess.Popen.wait`: when ``timeout`` is not ``None``, an efficient -event-driven mechanism now waits for process termination, if available: - -- Linux ≥= 5.3 uses :func:`os.pidfd_open` + :func:`select.poll` -- macOS and other BSD variants use :func:`select.kqueue` -- Windows keeps using ``WaitForSingleObject`` (unchanged) - -If none of these mechanisms are available, the function falls back to the -traditional busy loop (non-blocking call and short sleeps). - -Patch by Giampaolo Rodola. +event-driven mechanism now waits for process termination, if available. Linux +≥= 5.3 uses :func:`os.pidfd_open` + :func:`select.poll`. macOS and other BSD +variants use :func:`select.kqueue` + ``KQ_FILTER_PROC`` + ``KQ_NOTE_EXIT``. +Windows keeps using ``WaitForSingleObject`` (unchanged) If none of these +mechanisms are available, the function falls back to the traditional busy loop +(non-blocking call and short sleeps). Patch by Giampaolo Rodola. From 33c8b1fa3f6f532d0bc79afeba32970dde517bd0 Mon Sep 17 00:00:00 2001 From: Giampaolo Rodola Date: Mon, 19 Jan 2026 21:43:26 +0100 Subject: [PATCH 56/56] Minor rewordings --- Lib/subprocess.py | 6 +++--- .../Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index cbc1c686e092e0..43d352aec846a3 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2155,7 +2155,7 @@ def _wait_kqueue(self, timeout): else: if not events: raise TimeoutExpired(self.args, timeout) - return True + return True finally: kq.close() @@ -2178,8 +2178,8 @@ def _wait(self, timeout): # Try efficient wait first. if self._wait_pidfd(timeout) or self._wait_kqueue(timeout): # Process is gone. At this point os.waitpid(pid, 0) - # will return immediately, but in rare races the - # PID may have been reused. + # will return immediately, but in very rare races + # the PID may have been reused. # os.waitpid(pid, WNOHANG) ensures we attempt a # non-blocking reap without blocking indefinitely. with self._waitpid_lock: diff --git a/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst b/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst index ebe5e93abba130..7fa365c8485e14 100644 --- a/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst +++ b/Misc/NEWS.d/next/Library/2026-01-19-16-45-16.gh-issue-83069.0TaeH9.rst @@ -2,6 +2,6 @@ event-driven mechanism now waits for process termination, if available. Linux ≥= 5.3 uses :func:`os.pidfd_open` + :func:`select.poll`. macOS and other BSD variants use :func:`select.kqueue` + ``KQ_FILTER_PROC`` + ``KQ_NOTE_EXIT``. -Windows keeps using ``WaitForSingleObject`` (unchanged) If none of these +Windows keeps using ``WaitForSingleObject`` (unchanged). If none of these mechanisms are available, the function falls back to the traditional busy loop (non-blocking call and short sleeps). Patch by Giampaolo Rodola.