Skip to content

Commit 43b500f

Browse files
committed
Handle rare case where poll() says we're done, but waitpid() doesn't
1 parent 81275c8 commit 43b500f

File tree

2 files changed

+43
-4
lines changed

2 files changed

+43
-4
lines changed

Lib/subprocess.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2122,6 +2122,9 @@ def _wait(self, timeout):
21222122
return self.returncode
21232123

21242124
if timeout is not None:
2125+
started = _time()
2126+
endtime = started + timeout
2127+
21252128
# Try efficient wait first.
21262129
if self._wait_pidfd(timeout) or self._wait_kqueue(timeout):
21272130
# Process is gone. At this point os.waitpid(pid, 0)
@@ -2136,11 +2139,17 @@ def _wait(self, timeout):
21362139
assert pid == self.pid or pid == 0
21372140
if pid == self.pid:
21382141
self._handle_exitstatus(sts)
2139-
return self.returncode
2142+
return self.returncode
2143+
# os.waitpid(pid, WNOHANG) returned 0 instead
2144+
# of our PID, meaning PID has not yet exited,
2145+
# even though poll() / kqueue() said so. Very
2146+
# rare and mostly theoretical. Fallback to busy
2147+
# polling.
2148+
elapsed = _time() - started
2149+
endtime -= elapsed
21402150

21412151
# Enter a busy loop if we have a timeout. This busy loop was
21422152
# cribbed from Lib/threading.py in Thread.wait() at r71065.
2143-
endtime = _time() + timeout
21442153
delay = 0.0005 # 500 us -> initial delay of 1 ms
21452154
while True:
21462155
if self._waitpid_lock.acquire(False):

Lib/test/test_subprocess.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4091,8 +4091,8 @@ def test_broken_pipe_cleanup(self):
40914091

40924092

40934093
class FastWaitTestCase(BaseTestCase):
4094-
"""Tests for efficient (pidfd_open / kqueue) process waiting in
4095-
subprocess.Popen.wait().
4094+
"""Tests for efficient (pidfd_open() + poll() / kqueue()) process
4095+
waiting in subprocess.Popen.wait().
40964096
"""
40974097
CAN_USE_PIDFD_OPEN = hasattr(os, "pidfd_open")
40984098
CAN_USE_KQUEUE = subprocess._CAN_USE_KQUEUE
@@ -4164,6 +4164,36 @@ def test_pidfd_open_race(self):
41644164
def test_kqueue_race(self):
41654165
self.assert_wait_race_condition("select.kqueue", select.kqueue)
41664166

4167+
def assert_notification_without_immediate_reap(self, patch_target):
4168+
# Verify fallback to busy polling when poll() / kqueue()
4169+
# succeeds, but waitpid(pid, WNOHANG) returns (0, 0).
4170+
def waitpid_wrapper(pid, flags):
4171+
nonlocal ncalls
4172+
ncalls += 1
4173+
if ncalls == 1:
4174+
return (0, 0)
4175+
return real_waitpid(pid, flags)
4176+
4177+
ncalls = 0
4178+
real_waitpid = os.waitpid
4179+
with mock.patch.object(subprocess.Popen, patch_target, return_value=True) as m1:
4180+
with mock.patch("os.waitpid", side_effect=waitpid_wrapper) as m2:
4181+
p = subprocess.Popen([sys.executable,
4182+
"-c", "import time; time.sleep(0.3)"])
4183+
with self.assertRaises(subprocess.TimeoutExpired):
4184+
p.wait(timeout=0.0001)
4185+
self.assertEqual(p.wait(timeout=support.SHORT_TIMEOUT), 0)
4186+
assert m1.called
4187+
assert m2.called
4188+
4189+
@unittest.skipIf(not CAN_USE_PIDFD_OPEN, reason="LINUX only")
4190+
def test_poll_notification_without_immediate_reap(self):
4191+
self.assert_notification_without_immediate_reap("_wait_pidfd")
4192+
4193+
@unittest.skipIf(not CAN_USE_KQUEUE, reason="macOS / BSD only")
4194+
def test_kqueue_notification_without_immediate_reap(self):
4195+
self.assert_notification_without_immediate_reap("_wait_kqueue")
4196+
41674197

41684198
if __name__ == "__main__":
41694199
unittest.main()

0 commit comments

Comments
 (0)