Skip to content

Commit 872c85a

Browse files
bpo-37424: Avoid a hang in subprocess.run timeout output capture (GH-14490)
Fixes a possible hang when using a timeout on subprocess.run() while capturing output. If the child process spawned its own children or otherwise connected its stdout or stderr handles with another process, we could hang after the timeout was reached and our child was killed when attempting to read final output from the pipes. (cherry picked from commit 580d278) Co-authored-by: Gregory P. Smith <[email protected]>
1 parent f357cd0 commit 872c85a

File tree

3 files changed

+53
-9
lines changed

3 files changed

+53
-9
lines changed

Lib/subprocess.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -489,11 +489,20 @@ def run(*popenargs,
489489
with Popen(*popenargs, **kwargs) as process:
490490
try:
491491
stdout, stderr = process.communicate(input, timeout=timeout)
492-
except TimeoutExpired:
492+
except TimeoutExpired as exc:
493493
process.kill()
494-
stdout, stderr = process.communicate()
495-
raise TimeoutExpired(process.args, timeout, output=stdout,
496-
stderr=stderr)
494+
if _mswindows:
495+
# Windows accumulates the output in a single blocking
496+
# read() call run on child threads, with the timeout
497+
# being done in a join() on those threads. communicate()
498+
# _after_ kill() is required to collect that and add it
499+
# to the exception.
500+
exc.stdout, exc.stderr = process.communicate()
501+
else:
502+
# POSIX _communicate already populated the output so
503+
# far into the TimeoutExpired exception.
504+
process.wait()
505+
raise
497506
except: # Including KeyboardInterrupt, communicate handled that.
498507
process.kill()
499508
# We don't call process.wait() as .__exit__ does that for us.
@@ -1050,12 +1059,16 @@ def _remaining_time(self, endtime):
10501059
return endtime - _time()
10511060

10521061

1053-
def _check_timeout(self, endtime, orig_timeout):
1062+
def _check_timeout(self, endtime, orig_timeout, stdout_seq, stderr_seq,
1063+
skip_check_and_raise=False):
10541064
"""Convenience for checking if a timeout has expired."""
10551065
if endtime is None:
10561066
return
1057-
if _time() > endtime:
1058-
raise TimeoutExpired(self.args, orig_timeout)
1067+
if skip_check_and_raise or _time() > endtime:
1068+
raise TimeoutExpired(
1069+
self.args, orig_timeout,
1070+
output=b''.join(stdout_seq) if stdout_seq else None,
1071+
stderr=b''.join(stderr_seq) if stderr_seq else None)
10591072

10601073

10611074
def wait(self, timeout=None):
@@ -1843,10 +1856,15 @@ def _communicate(self, input, endtime, orig_timeout):
18431856
while selector.get_map():
18441857
timeout = self._remaining_time(endtime)
18451858
if timeout is not None and timeout < 0:
1846-
raise TimeoutExpired(self.args, orig_timeout)
1859+
self._check_timeout(endtime, orig_timeout,
1860+
stdout, stderr,
1861+
skip_check_and_raise=True)
1862+
raise RuntimeError( # Impossible :)
1863+
'_check_timeout(..., skip_check_and_raise=True) '
1864+
'failed to raise TimeoutExpired.')
18471865

18481866
ready = selector.select(timeout)
1849-
self._check_timeout(endtime, orig_timeout)
1867+
self._check_timeout(endtime, orig_timeout, stdout, stderr)
18501868

18511869
# XXX Rewrite these to use non-blocking I/O on the file
18521870
# objects; they are no longer using C stdio!

Lib/test/test_subprocess.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import errno
1111
import tempfile
1212
import time
13+
import traceback
1314
import selectors
1415
import sysconfig
1516
import select
@@ -1557,6 +1558,26 @@ def test_stderr_with_capture_output_arg(self):
15571558
self.assertIn('stderr', c.exception.args[0])
15581559
self.assertIn('capture_output', c.exception.args[0])
15591560

1561+
# This test _might_ wind up a bit fragile on loaded build+test machines
1562+
# as it depends on the timing with wide enough margins for normal situations
1563+
# but does assert that it happened "soon enough" to believe the right thing
1564+
# happened.
1565+
@unittest.skipIf(mswindows, "requires posix like 'sleep' shell command")
1566+
def test_run_with_shell_timeout_and_capture_output(self):
1567+
"""Output capturing after a timeout mustn't hang forever on open filehandles."""
1568+
before_secs = time.monotonic()
1569+
try:
1570+
subprocess.run('sleep 3', shell=True, timeout=0.1,
1571+
capture_output=True) # New session unspecified.
1572+
except subprocess.TimeoutExpired as exc:
1573+
after_secs = time.monotonic()
1574+
stacks = traceback.format_exc() # assertRaises doesn't give this.
1575+
else:
1576+
self.fail("TimeoutExpired not raised.")
1577+
self.assertLess(after_secs - before_secs, 1.5,
1578+
msg="TimeoutExpired was delayed! Bad traceback:\n```\n"
1579+
f"{stacks}```")
1580+
15601581

15611582
@unittest.skipIf(mswindows, "POSIX specific tests")
15621583
class POSIXProcessTestCase(BaseTestCase):
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Fixes a possible hang when using a timeout on `subprocess.run()` while
2+
capturing output. If the child process spawned its own children or
3+
otherwise connected its stdout or stderr handles with another process, we
4+
could hang after the timeout was reached and our child was killed when
5+
attempting to read final output from the pipes.

0 commit comments

Comments
 (0)