Skip to content

Commit f4d644f

Browse files
authored
bpo-25942: make subprocess more graceful on ^C (GH-5026)
Do not allow receiving a SIGINT to cause the subprocess module to trigger an immediate SIGKILL of the child process. SIGINT is normally sent to all child processes by the OS at the same time already as was the established normal behavior in 2.7 and 3.2. This behavior change was introduced during the fix to https://bugs.python.org/issue12494 and is generally surprising to command line tool users who expect other tools launched in child processes to get their own SIGINT and do their own cleanup. In Python 3.3-3.6 subprocess.call and subprocess.run would immediately SIGKILL the child process upon receiving a SIGINT (which raises a KeyboardInterrupt). We now give the child a small amount of time to exit gracefully before resorting to a SIGKILL. This is also the case for subprocess.Popen.__exit__ which would previously block indefinitely waiting for the child to die. This was hidden from many users by virtue of subprocess.call and subprocess.run sending the signal immediately. Behavior change: subprocess.Popen.__exit__ will not block indefinitely when the exiting exception is a KeyboardInterrupt. This is done for user friendliness as people expect their ^C to actually happen. This could cause occasional orphaned Popen objects when not using `call` or `run` with a child process that hasn't exited. Refactoring involved: The Popen.wait method deals with the KeyboardInterrupt second chance, existing platform specific internals have been renamed to _wait(). Also fixes comment typos.
1 parent 83e64c8 commit f4d644f

File tree

3 files changed

+143
-13
lines changed

3 files changed

+143
-13
lines changed

Lib/subprocess.py

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -304,9 +304,9 @@ def call(*popenargs, timeout=None, **kwargs):
304304
with Popen(*popenargs, **kwargs) as p:
305305
try:
306306
return p.wait(timeout=timeout)
307-
except:
307+
except: # Including KeyboardInterrupt, wait handled that.
308308
p.kill()
309-
p.wait()
309+
# We don't call p.wait() again as p.__exit__ does that for us.
310310
raise
311311

312312

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

715715
self.text_mode = encoding or errors or text or universal_newlines
716716

717+
# How long to resume waiting on a child after the first ^C.
718+
# There is no right value for this. The purpose is to be polite
719+
# yet remain good for interactive users trying to exit a tool.
720+
self._sigint_wait_secs = 0.25 # 1/xkcd221.getRandomNumber()
721+
717722
self._closed_child_pipe_fds = False
718723

719724
try:
@@ -787,7 +792,7 @@ def _translate_newlines(self, data, encoding, errors):
787792
def __enter__(self):
788793
return self
789794

790-
def __exit__(self, type, value, traceback):
795+
def __exit__(self, exc_type, value, traceback):
791796
if self.stdout:
792797
self.stdout.close()
793798
if self.stderr:
@@ -796,6 +801,22 @@ def __exit__(self, type, value, traceback):
796801
if self.stdin:
797802
self.stdin.close()
798803
finally:
804+
if exc_type == KeyboardInterrupt:
805+
# https://bugs.python.org/issue25942
806+
# In the case of a KeyboardInterrupt we assume the SIGINT
807+
# was also already sent to our child processes. We can't
808+
# block indefinitely as that is not user friendly.
809+
# If we have not already waited a brief amount of time in
810+
# an interrupted .wait() or .communicate() call, do so here
811+
# for consistency.
812+
if self._sigint_wait_secs > 0:
813+
try:
814+
self._wait(timeout=self._sigint_wait_secs)
815+
except TimeoutExpired:
816+
pass
817+
self._sigint_wait_secs = 0 # Note that this has been done.
818+
return # resume the KeyboardInterrupt
819+
799820
# Wait for the process to terminate, to avoid zombies.
800821
self.wait()
801822

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

890911
try:
891912
stdout, stderr = self._communicate(input, endtime, timeout)
913+
except KeyboardInterrupt:
914+
# https://bugs.python.org/issue25942
915+
# See the detailed comment in .wait().
916+
if timeout is not None:
917+
sigint_timeout = min(self._sigint_wait_secs,
918+
self._remaining_time(endtime))
919+
else:
920+
sigint_timeout = self._sigint_wait_secs
921+
self._sigint_wait_secs = 0 # nothing else should wait.
922+
try:
923+
self._wait(timeout=sigint_timeout)
924+
except TimeoutExpired:
925+
pass
926+
raise # resume the KeyboardInterrupt
927+
892928
finally:
893929
self._communication_started = True
894930

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

921957

958+
def wait(self, timeout=None):
959+
"""Wait for child process to terminate; returns self.returncode."""
960+
if timeout is not None:
961+
endtime = _time() + timeout
962+
try:
963+
return self._wait(timeout=timeout)
964+
except KeyboardInterrupt:
965+
# https://bugs.python.org/issue25942
966+
# The first keyboard interrupt waits briefly for the child to
967+
# exit under the common assumption that it also received the ^C
968+
# generated SIGINT and will exit rapidly.
969+
if timeout is not None:
970+
sigint_timeout = min(self._sigint_wait_secs,
971+
self._remaining_time(endtime))
972+
else:
973+
sigint_timeout = self._sigint_wait_secs
974+
self._sigint_wait_secs = 0 # nothing else should wait.
975+
try:
976+
self._wait(timeout=sigint_timeout)
977+
except TimeoutExpired:
978+
pass
979+
raise # resume the KeyboardInterrupt
980+
981+
922982
if _mswindows:
923983
#
924984
# Windows methods
@@ -1127,16 +1187,16 @@ def _internal_poll(self, _deadstate=None,
11271187
return self.returncode
11281188

11291189

1130-
def wait(self, timeout=None):
1131-
"""Wait for child process to terminate. Returns returncode
1132-
attribute."""
1190+
def _wait(self, timeout):
1191+
"""Internal implementation of wait() on Windows."""
11331192
if timeout is None:
11341193
timeout_millis = _winapi.INFINITE
11351194
else:
11361195
timeout_millis = int(timeout * 1000)
11371196
if self.returncode is None:
1197+
# API note: Returns immediately if timeout_millis == 0.
11381198
result = _winapi.WaitForSingleObject(self._handle,
1139-
timeout_millis)
1199+
timeout_millis)
11401200
if result == _winapi.WAIT_TIMEOUT:
11411201
raise TimeoutExpired(self.args, timeout)
11421202
self.returncode = _winapi.GetExitCodeProcess(self._handle)
@@ -1498,9 +1558,8 @@ def _try_wait(self, wait_flags):
14981558
return (pid, sts)
14991559

15001560

1501-
def wait(self, timeout=None):
1502-
"""Wait for child process to terminate. Returns returncode
1503-
attribute."""
1561+
def _wait(self, timeout):
1562+
"""Internal implementation of wait() on POSIX."""
15041563
if self.returncode is not None:
15051564
return self.returncode
15061565

Lib/test/test_subprocess.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2921,6 +2921,71 @@ def test_terminate_dead(self):
29212921
self._kill_dead_process('terminate')
29222922

29232923
class MiscTests(unittest.TestCase):
2924+
2925+
class RecordingPopen(subprocess.Popen):
2926+
"""A Popen that saves a reference to each instance for testing."""
2927+
instances_created = []
2928+
2929+
def __init__(self, *args, **kwargs):
2930+
super().__init__(*args, **kwargs)
2931+
self.instances_created.append(self)
2932+
2933+
@mock.patch.object(subprocess.Popen, "_communicate")
2934+
def _test_keyboardinterrupt_no_kill(self, popener, mock__communicate,
2935+
**kwargs):
2936+
"""Fake a SIGINT happening during Popen._communicate() and ._wait().
2937+
2938+
This avoids the need to actually try and get test environments to send
2939+
and receive signals reliably across platforms. The net effect of a ^C
2940+
happening during a blocking subprocess execution which we want to clean
2941+
up from is a KeyboardInterrupt coming out of communicate() or wait().
2942+
"""
2943+
2944+
mock__communicate.side_effect = KeyboardInterrupt
2945+
try:
2946+
with mock.patch.object(subprocess.Popen, "_wait") as mock__wait:
2947+
# We patch out _wait() as no signal was involved so the
2948+
# child process isn't actually going to exit rapidly.
2949+
mock__wait.side_effect = KeyboardInterrupt
2950+
with mock.patch.object(subprocess, "Popen",
2951+
self.RecordingPopen):
2952+
with self.assertRaises(KeyboardInterrupt):
2953+
popener([sys.executable, "-c",
2954+
"import time\ntime.sleep(9)\nimport sys\n"
2955+
"sys.stderr.write('\\n!runaway child!\\n')"],
2956+
stdout=subprocess.DEVNULL, **kwargs)
2957+
for call in mock__wait.call_args_list[1:]:
2958+
self.assertNotEqual(
2959+
call, mock.call(timeout=None),
2960+
"no open-ended wait() after the first allowed: "
2961+
f"{mock__wait.call_args_list}")
2962+
sigint_calls = []
2963+
for call in mock__wait.call_args_list:
2964+
if call == mock.call(timeout=0.25): # from Popen.__init__
2965+
sigint_calls.append(call)
2966+
self.assertLessEqual(mock__wait.call_count, 2,
2967+
msg=mock__wait.call_args_list)
2968+
self.assertEqual(len(sigint_calls), 1,
2969+
msg=mock__wait.call_args_list)
2970+
finally:
2971+
# cleanup the forgotten (due to our mocks) child process
2972+
process = self.RecordingPopen.instances_created.pop()
2973+
process.kill()
2974+
process.wait()
2975+
self.assertEqual([], self.RecordingPopen.instances_created)
2976+
2977+
def test_call_keyboardinterrupt_no_kill(self):
2978+
self._test_keyboardinterrupt_no_kill(subprocess.call, timeout=6.282)
2979+
2980+
def test_run_keyboardinterrupt_no_kill(self):
2981+
self._test_keyboardinterrupt_no_kill(subprocess.run, timeout=6.282)
2982+
2983+
def test_context_manager_keyboardinterrupt_no_kill(self):
2984+
def popen_via_context_manager(*args, **kwargs):
2985+
with subprocess.Popen(*args, **kwargs) as unused_process:
2986+
raise KeyboardInterrupt # Test how __exit__ handles ^C.
2987+
self._test_keyboardinterrupt_no_kill(popen_via_context_manager)
2988+
29242989
def test_getoutput(self):
29252990
self.assertEqual(subprocess.getoutput('echo xyzzy'), 'xyzzy')
29262991
self.assertEqual(subprocess.getstatusoutput('echo xyzzy'),
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
The subprocess module is now more graceful when handling a Ctrl-C
2+
KeyboardInterrupt during subprocess.call, subprocess.run, or a Popen context
3+
manager. It now waits a short amount of time for the child (presumed to
4+
have also gotten the SIGINT) to exit, before continuing the
5+
KeyboardInterrupt exception handling. This still includes a SIGKILL in the
6+
call() and run() APIs, but at least the child had a chance first.

0 commit comments

Comments
 (0)