From 1c4899a271f404825675be43866adb915051a497 2020-02-27 16:24:32 From: Matthias Bussonnier Date: 2020-02-27 16:24:32 Subject: [PATCH] Backport PR #12137: Fix inability to interrupt processes on Windows --- diff --git a/IPython/utils/_process_win32.py b/IPython/utils/_process_win32.py index fb17e4c..6d05bda 100644 --- a/IPython/utils/_process_win32.py +++ b/IPython/utils/_process_win32.py @@ -18,10 +18,12 @@ This file is only meant to be imported by process.py, not by end-users. import os import sys import ctypes +import time from ctypes import c_int, POINTER from ctypes.wintypes import LPCWSTR, HLOCAL -from subprocess import STDOUT +from subprocess import STDOUT, TimeoutExpired +from threading import Thread # our own imports from ._process_common import read_no_interrupt, process_handler, arg_split as py_arg_split @@ -93,15 +95,29 @@ def _find_cmd(cmd): def _system_body(p): """Callback for _system.""" enc = DEFAULT_ENCODING - for line in read_no_interrupt(p.stdout).splitlines(): - line = line.decode(enc, 'replace') - print(line, file=sys.stdout) - for line in read_no_interrupt(p.stderr).splitlines(): - line = line.decode(enc, 'replace') - print(line, file=sys.stderr) - # Wait to finish for returncode - return p.wait() + def stdout_read(): + for line in read_no_interrupt(p.stdout).splitlines(): + line = line.decode(enc, 'replace') + print(line, file=sys.stdout) + + def stderr_read(): + for line in read_no_interrupt(p.stderr).splitlines(): + line = line.decode(enc, 'replace') + print(line, file=sys.stderr) + + Thread(target=stdout_read).start() + Thread(target=stderr_read).start() + + # Wait to finish for returncode. Unfortunately, Python has a bug where + # wait() isn't interruptible (https://bugs.python.org/issue28168) so poll in + # a loop instead of just doing `return p.wait()`. + while True: + result = p.poll() + if result is None: + time.sleep(0.01) + else: + return result def system(cmd): @@ -116,9 +132,7 @@ def system(cmd): Returns ------- - None : we explicitly do NOT return the subprocess status code, as this - utility is meant to be used extensively in IPython, where any return value - would trigger :func:`sys.displayhook` calls. + int : child process' exit code. """ # The controller provides interactivity with both # stdin and stdout diff --git a/IPython/utils/tests/test_process.py b/IPython/utils/tests/test_process.py index 0c81138..fbc000c 100644 --- a/IPython/utils/tests/test_process.py +++ b/IPython/utils/tests/test_process.py @@ -15,13 +15,19 @@ Tests for platutils.py #----------------------------------------------------------------------------- import sys +import signal import os +import time +from _thread import interrupt_main # Py 3 +import threading +from unittest import SkipTest import nose.tools as nt from IPython.utils.process import (find_cmd, FindCmdError, arg_split, system, getoutput, getoutputerror, get_output_error_code) +from IPython.utils.capture import capture_output from IPython.testing import decorators as dec from IPython.testing import tools as tt @@ -107,6 +113,49 @@ class SubProcessTestCase(tt.TempFileMixin): status = system('%s -c "import sys"' % python) self.assertEqual(status, 0) + def assert_interrupts(self, command): + """ + Interrupt a subprocess after a second. + """ + if threading.main_thread() != threading.current_thread(): + raise nt.SkipTest("Can't run this test if not in main thread.") + + # Some tests can overwrite SIGINT handler (by using pdb for example), + # which then breaks this test, so just make sure it's operating + # normally. + signal.signal(signal.SIGINT, signal.default_int_handler) + + def interrupt(): + # Wait for subprocess to start: + time.sleep(0.5) + interrupt_main() + + threading.Thread(target=interrupt).start() + start = time.time() + try: + result = command() + except KeyboardInterrupt: + # Success! + pass + end = time.time() + self.assertTrue( + end - start < 2, "Process didn't die quickly: %s" % (end - start) + ) + return result + + def test_system_interrupt(self): + """ + When interrupted in the way ipykernel interrupts IPython, the + subprocess is interrupted. + """ + def command(): + return system('%s -c "import time; time.sleep(5)"' % python) + + status = self.assert_interrupts(command) + self.assertNotEqual( + status, 0, "The process wasn't interrupted. Status: %s" % (status,) + ) + def test_getoutput(self): out = getoutput('%s "%s"' % (python, self.fname)) # we can't rely on the order the line buffered streams are flushed @@ -131,7 +180,7 @@ class SubProcessTestCase(tt.TempFileMixin): out, err = getoutputerror('%s "%s"' % (python, self.fname)) self.assertEqual(out, 'on stdout') self.assertEqual(err, 'on stderr') - + def test_get_output_error_code(self): quiet_exit = '%s -c "import sys; sys.exit(1)"' % python out, err, code = get_output_error_code(quiet_exit) @@ -142,3 +191,5 @@ class SubProcessTestCase(tt.TempFileMixin): self.assertEqual(out, 'on stdout') self.assertEqual(err, 'on stderr') self.assertEqual(code, 0) + +