From ba1f9aa5e7a536f22799c0b6cd9cb7b3ed7b87fe Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Sat, 18 Jul 2026 16:35:51 +0300 Subject: [PATCH] gh-153962: Re-raise unexpected OSError in subprocess Popen._internal_poll On POSIX, Popen._internal_poll() caught every OSError from os.waitpid() and, unless it was ECHILD or the call came from __del__ (a non-None _deadstate), silently discarded it, leaving returncode as None and masking a real failure. Add an else branch that re-raises such unexpected errors. The re-raise is only reachable when _deadstate is None; __del__ and _cleanup() always pass a non-None _deadstate, so __del__ still never raises. ECHILD handling is unchanged. --- Lib/subprocess.py | 7 +++++++ Lib/test/test_subprocess.py | 19 +++++++++++++++++++ ...-07-18-16-35-33.gh-issue-153962.Kp7xQ2.rst | 4 ++++ 3 files changed, 30 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-07-18-16-35-33.gh-issue-153962.Kp7xQ2.rst diff --git a/Lib/subprocess.py b/Lib/subprocess.py index 6fe2ec98fb40888..69f8ee6776e4f5f 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -2191,6 +2191,13 @@ def _internal_poll(self, _deadstate=None, _del_safe=_del_safe): # can't get the status. # http://bugs.python.org/issue15756 self.returncode = 0 + else: + # An unexpected error (e.g. EINVAL) must not be + # silently swallowed, leaving returncode as None: + # surface it to the caller. This branch is never + # reached from __del__, which always passes a + # non-None _deadstate. + raise finally: self._waitpid_lock.release() return self.returncode diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index d066ae85dfc51a6..4bc97f39b4d3aff 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -3419,6 +3419,25 @@ def test_leak_fast_process_del_killed(self): else: self.assertNotIn(ident, [id(o) for o in subprocess._active]) + def test_internal_poll_reraises_unexpected_oserror(self): + # An unexpected OSError from waitpid() (i.e. not ECHILD, and not + # during the __del__/_deadstate path) must not be silently swallowed + # by poll(); doing so would leave returncode as None and mask a real + # failure. + p = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(3)"]) + self.addCleanup(p.wait) + self.addCleanup(p.kill) + + def unexpected_waitpid(pid, flags): + raise OSError(errno.EINVAL, "simulated unexpected error") + + with mock.patch.object(subprocess._del_safe, "waitpid", + unexpected_waitpid): + with self.assertRaises(OSError) as cm: + p.poll() + self.assertEqual(cm.exception.errno, errno.EINVAL) + self.assertIsNone(p.returncode) + def test_close_fds_after_preexec(self): fd_status = support.findfile("fd_status.py", subdir="subprocessdata") diff --git a/Misc/NEWS.d/next/Library/2026-07-18-16-35-33.gh-issue-153962.Kp7xQ2.rst b/Misc/NEWS.d/next/Library/2026-07-18-16-35-33.gh-issue-153962.Kp7xQ2.rst new file mode 100644 index 000000000000000..461117fa526b060 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-18-16-35-33.gh-issue-153962.Kp7xQ2.rst @@ -0,0 +1,4 @@ +On POSIX, :meth:`subprocess.Popen.poll` now re-raises an unexpected +:exc:`OSError` from the underlying :func:`os.waitpid` call instead of +silently ignoring it and leaving :attr:`!returncode` set to ``None``. The +special handling of :const:`~errno.ECHILD` is unchanged.