diff options
author | Mehdi ABAAKOUK <sileht@sileht.net> | 2020-01-09 15:57:43 +0100 |
---|---|---|
committer | Bernát Gábor <bgabor8@bloomberg.net> | 2020-01-09 14:57:43 +0000 |
commit | e639a54eab5641c8dde353a29a516e4bf5487e37 (patch) | |
tree | 9d31601efcea78c8e6d1cbd674f50e80ddddf5e5 | |
parent | 6472eac8fc4f0c222d9f40c4572aafbca72c40db (diff) | |
download | tox-git-e639a54eab5641c8dde353a29a516e4bf5487e37.tar.gz |
Allow to config the behavior on CTRL+C (#1493)
By default, we run SIGKILL after 0.5 seconds. Most of the time is
enough. But if the interrupted command have a complex processes tree,
it might not be enough to propagate the signal.
In such case processes are left behind and never killed.
If theses processes use static network port or keep file open. Next
call of tox will fail until the all processes left behind are manually
killed.
This change adds some configuration to be able to config the timeout
before signals are sent.
If the approach work for you, I will polish the PR (doc+test)
-rw-r--r-- | CONTRIBUTORS | 1 | ||||
-rw-r--r-- | docs/changelog/1493.feature.rst | 1 | ||||
-rw-r--r-- | docs/config.rst | 15 | ||||
-rw-r--r-- | src/tox/action.py | 25 | ||||
-rw-r--r-- | src/tox/config/__init__.py | 42 | ||||
-rw-r--r-- | src/tox/session/__init__.py | 4 | ||||
-rw-r--r-- | src/tox/venv.py | 2 | ||||
-rw-r--r-- | tests/unit/config/test_config.py | 19 |
8 files changed, 100 insertions, 9 deletions
diff --git a/CONTRIBUTORS b/CONTRIBUTORS index f944ddb2..5701b5b1 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -57,6 +57,7 @@ Mark Hirota Matt Good Matt Jeffery Mattieu Agopian +Mehdi Abaakouk Michael Manganiello Mickaël Schoentgen Mikhail Kyshtymov diff --git a/docs/changelog/1493.feature.rst b/docs/changelog/1493.feature.rst new file mode 100644 index 00000000..20f8fb30 --- /dev/null +++ b/docs/changelog/1493.feature.rst @@ -0,0 +1 @@ +Add ``interrupt_timeout`` and ``terminate_timeout`` that configure delay between SIGINT, SIGTERM and SIGKILL when tox is interrupted. - by :user:`sileht` diff --git a/docs/config.rst b/docs/config.rst index 8d9dbb74..4eba670c 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -160,6 +160,21 @@ Global settings are defined under the ``tox`` section as: Name of the virtual environment used to create a source distribution from the source tree. +.. conf:: interrupt_timeout ^ float ^ 0.3 + + .. versionadded:: 3.15.0 + + When tox is interrupted, it propagates the signal to the child process, + wait :conf:``interrupt_timeout`` seconds, and sends it a SIGTERM if it haven't + exited. + +.. conf:: terminate_timeout ^ float ^ 0.2 + + .. versionadded:: 3.15.0 + + When tox is interrupted, it propagates the signal to the child process, + wait :conf:``interrupt_timeout`` seconds, sends it a SIGTERM, wait + :conf:``terminate_timeout`` seconds, and sends it a SIGKILL if it haven't exited. Jenkins override ++++++++++++++++ diff --git a/src/tox/action.py b/src/tox/action.py index 10707b48..6dae0d07 100644 --- a/src/tox/action.py +++ b/src/tox/action.py @@ -18,14 +18,23 @@ from tox.reporter import Verbosity from tox.util.lock import get_unique_file from tox.util.stdlib import is_main_thread -WAIT_INTERRUPT = 0.3 -WAIT_TERMINATE = 0.2 - class Action(object): """Action is an effort to group operations with the same goal (within reporting)""" - def __init__(self, name, msg, args, log_dir, generate_tox_log, command_log, popen, python): + def __init__( + self, + name, + msg, + args, + log_dir, + generate_tox_log, + command_log, + popen, + python, + interrupt_timeout, + terminate_timeout, + ): self.name = name self.args = args self.msg = msg @@ -36,6 +45,8 @@ class Action(object): self.command_log = command_log self._timed_report = None self.python = python + self.interrupt_timeout = interrupt_timeout + self.terminate_timeout = terminate_timeout def __enter__(self): msg = "{} {}".format(self.msg, " ".join(map(str, self.args))) @@ -180,10 +191,10 @@ class Action(object): if process.poll() is None: self.info("KeyboardInterrupt", msg.format("SIGINT")) process.send_signal(signal.CTRL_C_EVENT if sys.platform == "win32" else signal.SIGINT) - if self._wait(process, WAIT_INTERRUPT) is None: + if self._wait(process, self.interrupt_timeout) is None: self.info("KeyboardInterrupt", msg.format("SIGTERM")) process.terminate() - if self._wait(process, WAIT_TERMINATE) is None: + if self._wait(process, self.terminate_timeout) is None: self.info("KeyboardInterrupt", msg.format("SIGKILL")) process.kill() process.communicate() @@ -193,7 +204,7 @@ class Action(object): if sys.version_info >= (3, 3): # python 3 has timeout feature built-in try: - process.communicate(timeout=WAIT_INTERRUPT) + process.communicate(timeout=timeout) except subprocess.TimeoutExpired: pass else: diff --git a/src/tox/config/__init__.py b/src/tox/config/__init__.py index d80da5ff..3b612676 100644 --- a/src/tox/config/__init__.py +++ b/src/tox/config/__init__.py @@ -54,6 +54,9 @@ Import hookimpl directly from tox instead. WITHIN_PROVISION = os.environ.get(str("TOX_PROVISION")) == "1" +INTERRUPT_TIMEOUT = 0.3 +TERMINATE_TIMEOUT = 0.2 + def get_plugin_manager(plugins=()): # initialize plugin manager @@ -799,6 +802,20 @@ def tox_addoption(parser): parser.add_testenv_attribute_obj(DepOption()) parser.add_testenv_attribute( + name="interrupt_timeout", + type="float", + default=INTERRUPT_TIMEOUT, + help="timeout before sending SIGTERM after SIGINT", + ) + + parser.add_testenv_attribute( + name="terminate_timeout", + type="float", + default=TERMINATE_TIMEOUT, + help="timeout before sending SIGKILL after SIGTERM", + ) + + parser.add_testenv_attribute( name="commands", type="argvlist", default="", @@ -1231,7 +1248,16 @@ class ParseIni(object): for env_attr in config._testenv_attr: atype = env_attr.type try: - if atype in ("bool", "path", "string", "dict", "dict_setenv", "argv", "argvlist"): + if atype in ( + "bool", + "float", + "path", + "string", + "dict", + "dict_setenv", + "argv", + "argvlist", + ): meth = getattr(reader, "get{}".format(atype)) res = meth(env_attr.name, env_attr.default, replace=replace) elif atype == "basepython": @@ -1448,6 +1474,20 @@ class SectionReader: return d + def getfloat(self, name, default=None, replace=True): + s = self.getstring(name, default, replace=replace) + if not s or not replace: + s = default + if s is None: + raise KeyError("no config value [{}] {} found".format(self.section_name, name)) + + if not isinstance(s, float): + try: + s = float(s) + except ValueError: + raise tox.exception.ConfigError("{}: invalid float {!r}".format(name, s)) + return s + def getbool(self, name, default=None, replace=True): s = self.getstring(name, default, replace=replace) if not s or not replace: diff --git a/src/tox/session/__init__.py b/src/tox/session/__init__.py index b2e901e1..a2e0ca78 100644 --- a/src/tox/session/__init__.py +++ b/src/tox/session/__init__.py @@ -19,7 +19,7 @@ import py import tox from tox import reporter from tox.action import Action -from tox.config import parseconfig +from tox.config import INTERRUPT_TIMEOUT, TERMINATE_TIMEOUT, parseconfig from tox.config.parallel import ENV_VAR_KEY_PRIVATE as PARALLEL_ENV_VAR_KEY_PRIVATE from tox.config.parallel import OFF_VALUE as PARALLEL_OFF from tox.logs.result import ResultLog @@ -170,6 +170,8 @@ class Session(object): self.resultlog.command_log, self.popen, sys.executable, + INTERRUPT_TIMEOUT, + TERMINATE_TIMEOUT, ) def runcommand(self): diff --git a/src/tox/venv.py b/src/tox/venv.py index 4edf741b..0cd78c24 100644 --- a/src/tox/venv.py +++ b/src/tox/venv.py @@ -130,6 +130,8 @@ class VirtualEnv(object): command_log, self.popen, self.envconfig.envpython, + self.envconfig.interrupt_timeout, + self.envconfig.terminate_timeout, ) def get_result_json_path(self): diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 9fd5b23c..a717b8c3 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -175,6 +175,25 @@ class TestVenvConfig: assert DepOption._is_same_dep("pkg_hello-world3==1.0", "pkg_hello-world3<=2.0") assert not DepOption._is_same_dep("pkg_hello-world3==1.0", "otherpkg>=2.0") + def test_interrupt_terminate_timeout_set_manually(self, newconfig): + config = newconfig( + [], + """ + [testenv:dev] + interrupt_timeout = 5.0 + terminate_timeout = 10.0 + + [testenv:other] + """, + ) + envconfig = config.envconfigs["other"] + assert 0.3 == envconfig.interrupt_timeout + assert 0.2 == envconfig.terminate_timeout + + envconfig = config.envconfigs["dev"] + assert 5.0 == envconfig.interrupt_timeout + assert 10.0 == envconfig.terminate_timeout + class TestConfigPlatform: def test_config_parse_platform(self, newconfig): |