Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 72 additions & 13 deletions Lib/subprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,9 +304,9 @@ def call(*popenargs, timeout=None, **kwargs):
with Popen(*popenargs, **kwargs) as p:
try:
return p.wait(timeout=timeout)
except:
except: # Including KeyboardInterrupt, wait handled that.
p.kill()
p.wait()
# We don't call p.wait() again as p.__exit__ does that for us.
raise


Expand Down Expand Up @@ -450,9 +450,9 @@ def run(*popenargs, input=None, timeout=None, check=False, **kwargs):
stdout, stderr = process.communicate()
raise TimeoutExpired(process.args, timeout, output=stdout,
stderr=stderr)
except:
except: # Including KeyboardInterrupt, communicate handled that.
process.kill()
process.wait()
# We don't call process.wait() as .__exit__ does that for us.
raise
retcode = process.poll()
if check and retcode:
Expand Down Expand Up @@ -714,6 +714,11 @@ def __init__(self, args, bufsize=-1, executable=None,

self.text_mode = encoding or errors or text or universal_newlines

# How long to resume waiting on a child after the first ^C.
# There is no right value for this. The purpose is to be polite
# yet remain good for interactive users trying to exit a tool.
self._sigint_wait_secs = 0.25 # 1/xkcd221.getRandomNumber()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


self._closed_child_pipe_fds = False

try:
Expand Down Expand Up @@ -787,7 +792,7 @@ def _translate_newlines(self, data, encoding, errors):
def __enter__(self):
return self

def __exit__(self, type, value, traceback):
def __exit__(self, exc_type, value, traceback):
if self.stdout:
self.stdout.close()
if self.stderr:
Expand All @@ -796,6 +801,22 @@ def __exit__(self, type, value, traceback):
if self.stdin:
self.stdin.close()
finally:
if exc_type == KeyboardInterrupt:
# https://bugs.python.org/issue25942
# In the case of a KeyboardInterrupt we assume the SIGINT
# was also already sent to our child processes. We can't
# block indefinitely as that is not user friendly.
# If we have not already waited a brief amount of time in
# an interrupted .wait() or .communicate() call, do so here
# for consistency.
if self._sigint_wait_secs > 0:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this code is repeated is several places (wait, communicate), how about factoring it out?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exit and the Popen instance have no knowledge of timeouts or whether the child process should be killed and in what circumstances. I didn't want to move that into the Popen instance.

If I do, it would be internal-only APIs. I don't want to complicate the public Popen API surface further.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could simply have a self._handle_keyboard_interrupt(timeout) helper doing the _sigint_wait_secs dance. You would also centralize the comment there.

try:
self._wait(timeout=self._sigint_wait_secs)
except TimeoutExpired:
pass
self._sigint_wait_secs = 0 # Note that this has been done.
return # resume the KeyboardInterrupt

# Wait for the process to terminate, to avoid zombies.
self.wait()

Expand All @@ -804,7 +825,7 @@ def __del__(self, _maxsize=sys.maxsize, _warn=warnings.warn):
# We didn't get to successfully create a child process.
return
if self.returncode is None:
# Not reading subprocess exit status creates a zombi process which
# Not reading subprocess exit status creates a zombie process which
# is only destroyed at the parent python process exit
_warn("subprocess %s is still running" % self.pid,
ResourceWarning, source=self)
Expand Down Expand Up @@ -889,6 +910,21 @@ def communicate(self, input=None, timeout=None):

try:
stdout, stderr = self._communicate(input, endtime, timeout)
except KeyboardInterrupt:
# https://bugs.python.org/issue25942
# See the detailed comment in .wait().
if timeout is not None:
sigint_timeout = min(self._sigint_wait_secs,
self._remaining_time(endtime))
else:
sigint_timeout = self._sigint_wait_secs
self._sigint_wait_secs = 0 # nothing else should wait.
try:
self._wait(timeout=sigint_timeout)
except TimeoutExpired:
pass
raise # resume the KeyboardInterrupt

finally:
self._communication_started = True

Expand Down Expand Up @@ -919,6 +955,30 @@ def _check_timeout(self, endtime, orig_timeout):
raise TimeoutExpired(self.args, orig_timeout)


def wait(self, timeout=None):
"""Wait for child process to terminate; returns self.returncode."""
if timeout is not None:
endtime = _time() + timeout
try:
return self._wait(timeout=timeout)
except KeyboardInterrupt:
# https://bugs.python.org/issue25942
# The first keyboard interrupt waits briefly for the child to
# exit under the common assumption that it also received the ^C
# generated SIGINT and will exit rapidly.
if timeout is not None:
sigint_timeout = min(self._sigint_wait_secs,
self._remaining_time(endtime))
else:
sigint_timeout = self._sigint_wait_secs
self._sigint_wait_secs = 0 # nothing else should wait.
try:
self._wait(timeout=sigint_timeout)
except TimeoutExpired:
pass
raise # resume the KeyboardInterrupt


if _mswindows:
#
# Windows methods
Expand Down Expand Up @@ -1127,16 +1187,16 @@ def _internal_poll(self, _deadstate=None,
return self.returncode


def wait(self, timeout=None):
"""Wait for child process to terminate. Returns returncode
attribute."""
def _wait(self, timeout):
"""Internal implementation of wait() on Windows."""
if timeout is None:
timeout_millis = _winapi.INFINITE
else:
timeout_millis = int(timeout * 1000)
if self.returncode is None:
# API note: Returns immediately if timeout_millis == 0.
result = _winapi.WaitForSingleObject(self._handle,
timeout_millis)
timeout_millis)
if result == _winapi.WAIT_TIMEOUT:
raise TimeoutExpired(self.args, timeout)
self.returncode = _winapi.GetExitCodeProcess(self._handle)
Expand Down Expand Up @@ -1498,9 +1558,8 @@ def _try_wait(self, wait_flags):
return (pid, sts)


def wait(self, timeout=None):
"""Wait for child process to terminate. Returns returncode
attribute."""
def _wait(self, timeout):
"""Internal implementation of wait() on POSIX."""
if self.returncode is not None:
return self.returncode

Expand Down
65 changes: 65 additions & 0 deletions Lib/test/test_subprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -2922,6 +2922,71 @@ def test_terminate_dead(self):
self._kill_dead_process('terminate')

class MiscTests(unittest.TestCase):

class RecordingPopen(subprocess.Popen):
"""A Popen that saves a reference to each instance for testing."""
instances_created = []

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.instances_created.append(self)

@mock.patch.object(subprocess.Popen, "_communicate")
def _test_keyboardinterrupt_no_kill(self, popener, mock__communicate,
**kwargs):
"""Fake a SIGINT happening during Popen._communicate() and ._wait().

This avoids the need to actually try and get test environments to send
and receive signals reliably across platforms. The net effect of a ^C
happening during a blocking subprocess execution which we want to clean
up from is a KeyboardInterrupt coming out of communicate() or wait().
"""

mock__communicate.side_effect = KeyboardInterrupt
try:
with mock.patch.object(subprocess.Popen, "_wait") as mock__wait:
# We patch out _wait() as no signal was involved so the
# child process isn't actually going to exit rapidly.
mock__wait.side_effect = KeyboardInterrupt
with mock.patch.object(subprocess, "Popen",
self.RecordingPopen):
with self.assertRaises(KeyboardInterrupt):
popener([sys.executable, "-c",
"import time\ntime.sleep(9)\nimport sys\n"
"sys.stderr.write('\\n!runaway child!\\n')"],
stdout=subprocess.DEVNULL, **kwargs)
for call in mock__wait.call_args_list[1:]:
self.assertNotEqual(
call, mock.call(timeout=None),
"no open-ended wait() after the first allowed: "
f"{mock__wait.call_args_list}")
sigint_calls = []
for call in mock__wait.call_args_list:
if call == mock.call(timeout=0.25): # from Popen.__init__
sigint_calls.append(call)
self.assertLessEqual(mock__wait.call_count, 2,
msg=mock__wait.call_args_list)
self.assertEqual(len(sigint_calls), 1,
msg=mock__wait.call_args_list)
finally:
# cleanup the forgotten (due to our mocks) child process
process = self.RecordingPopen.instances_created.pop()
process.kill()
process.wait()
self.assertEqual([], self.RecordingPopen.instances_created)

def test_call_keyboardinterrupt_no_kill(self):
self._test_keyboardinterrupt_no_kill(subprocess.call, timeout=6.282)

def test_run_keyboardinterrupt_no_kill(self):
self._test_keyboardinterrupt_no_kill(subprocess.run, timeout=6.282)

def test_context_manager_keyboardinterrupt_no_kill(self):
def popen_via_context_manager(*args, **kwargs):
with subprocess.Popen(*args, **kwargs) as unused_process:
raise KeyboardInterrupt # Test how __exit__ handles ^C.
self._test_keyboardinterrupt_no_kill(popen_via_context_manager)

def test_getoutput(self):
self.assertEqual(subprocess.getoutput('echo xyzzy'), 'xyzzy')
self.assertEqual(subprocess.getstatusoutput('echo xyzzy'),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
The subprocess module is now more graceful when handling a Ctrl-C
KeyboardInterrupt during subprocess.call, subprocess.run, or a Popen context
manager. It now waits a short amount of time for the child (presumed to
have also gotten the SIGINT) to exit, before continuing the
KeyboardInterrupt exception handling. This still includes a SIGKILL in the
call() and run() APIs, but at least the child had a chance first.