Skip to content

Commit 03e6153

Browse files
committed
Allow to config the behavior on CTRL+C
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)
1 parent 6472eac commit 03e6153

File tree

8 files changed

+93
-8
lines changed

8 files changed

+93
-8
lines changed

CONTRIBUTORS

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ Mark Hirota
5757
Matt Good
5858
Matt Jeffery
5959
Mattieu Agopian
60+
Mehdi Abaakouk
6061
Michael Manganiello
6162
Mickaël Schoentgen
6263
Mikhail Kyshtymov

docs/changelog.rst

+10
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@ with advance notice in the **Deprecations** section of releases.
1111

1212
.. towncrier release notes start
1313
14+
v3.15.0 (2019-12-27)
15+
--------------------
16+
17+
Features
18+
^^^^^^^^
19+
20+
- add ``interrupt_timeout`` and ``terminate_timeout`` that configure delay
21+
between SIGINT, SIGTERM and SIGKILL when tox is interrupted. - by :user:`sileht`
22+
`#1493 https://github.com/tox-dev/tox/issues/1493`_
23+
1424
v3.14.3 (2019-12-27)
1525
--------------------
1626

docs/config.rst

+15
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,21 @@ Global settings are defined under the ``tox`` section as:
160160
Name of the virtual environment used to create a source distribution from the
161161
source tree.
162162

163+
.. conf:: interrupt_timeout ^ float ^ 0.3
164+
165+
.. versionadded:: 3.15.0
166+
167+
When tox is interrupted, it propagates the signal to the child process,
168+
wait `interrupt_timeout` seconds, and sends it a SIGTERM if it haven't
169+
exited.
170+
171+
.. conf:: terminate_timeout ^ float ^ 0.2
172+
173+
.. versionadded:: 3.15.0
174+
175+
When tox is interrupted, it propagates the signal to the child process,
176+
wait `interrupt_timeout` seconds, sends it a SIGTERM, wait
177+
`terminate_timeout` seconds, and sends it a SIGKILL if it haven't exited.
163178

164179
Jenkins override
165180
++++++++++++++++

src/tox/action.py

+7-7
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,12 @@
1818
from tox.util.lock import get_unique_file
1919
from tox.util.stdlib import is_main_thread
2020

21-
WAIT_INTERRUPT = 0.3
22-
WAIT_TERMINATE = 0.2
23-
2421

2522
class Action(object):
2623
"""Action is an effort to group operations with the same goal (within reporting)"""
2724

28-
def __init__(self, name, msg, args, log_dir, generate_tox_log, command_log, popen, python):
25+
def __init__(self, name, msg, args, log_dir, generate_tox_log, command_log, popen,
26+
python, interrupt_timeout, terminate_timeout):
2927
self.name = name
3028
self.args = args
3129
self.msg = msg
@@ -36,6 +34,8 @@ def __init__(self, name, msg, args, log_dir, generate_tox_log, command_log, pope
3634
self.command_log = command_log
3735
self._timed_report = None
3836
self.python = python
37+
self.interrupt_timeout = interrupt_timeout
38+
self.terminate_timeout = terminate_timeout
3939

4040
def __enter__(self):
4141
msg = "{} {}".format(self.msg, " ".join(map(str, self.args)))
@@ -180,10 +180,10 @@ def handle_interrupt(self, process):
180180
if process.poll() is None:
181181
self.info("KeyboardInterrupt", msg.format("SIGINT"))
182182
process.send_signal(signal.CTRL_C_EVENT if sys.platform == "win32" else signal.SIGINT)
183-
if self._wait(process, WAIT_INTERRUPT) is None:
183+
if self._wait(process, self.interrupt_timeout) is None:
184184
self.info("KeyboardInterrupt", msg.format("SIGTERM"))
185185
process.terminate()
186-
if self._wait(process, WAIT_TERMINATE) is None:
186+
if self._wait(process, self.terminate_timeout) is None:
187187
self.info("KeyboardInterrupt", msg.format("SIGKILL"))
188188
process.kill()
189189
process.communicate()
@@ -193,7 +193,7 @@ def _wait(process, timeout):
193193
if sys.version_info >= (3, 3):
194194
# python 3 has timeout feature built-in
195195
try:
196-
process.communicate(timeout=WAIT_INTERRUPT)
196+
process.communicate(timeout=timeout)
197197
except subprocess.TimeoutExpired:
198198
pass
199199
else:

src/tox/config/__init__.py

+35-1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@
5454

5555
WITHIN_PROVISION = os.environ.get(str("TOX_PROVISION")) == "1"
5656

57+
INTERRUPT_TIMEOUT = 0.3
58+
TERMINATE_TIMEOUT = 0.2
59+
5760

5861
def get_plugin_manager(plugins=()):
5962
# initialize plugin manager
@@ -798,6 +801,20 @@ def develop(testenv_config, value):
798801

799802
parser.add_testenv_attribute_obj(DepOption())
800803

804+
parser.add_testenv_attribute(
805+
name="interrupt_timeout",
806+
type="float",
807+
default=INTERRUPT_TIMEOUT,
808+
help="timeout before sending SIGTERM after SIGINT",
809+
)
810+
811+
parser.add_testenv_attribute(
812+
name="terminate_timeout",
813+
type="float",
814+
default=TERMINATE_TIMEOUT,
815+
help="timeout before sending SIGKILL after SIGTERM",
816+
)
817+
801818
parser.add_testenv_attribute(
802819
name="commands",
803820
type="argvlist",
@@ -1231,7 +1248,8 @@ def make_envconfig(self, name, section, subs, config, replace=True):
12311248
for env_attr in config._testenv_attr:
12321249
atype = env_attr.type
12331250
try:
1234-
if atype in ("bool", "path", "string", "dict", "dict_setenv", "argv", "argvlist"):
1251+
if atype in ("bool", "float", "path", "string",
1252+
"dict", "dict_setenv", "argv", "argvlist"):
12351253
meth = getattr(reader, "get{}".format(atype))
12361254
res = meth(env_attr.name, env_attr.default, replace=replace)
12371255
elif atype == "basepython":
@@ -1448,6 +1466,22 @@ def _getdict(self, value, default, sep, replace=True):
14481466

14491467
return d
14501468

1469+
def getfloat(self, name, default=None, replace=True):
1470+
s = self.getstring(name, default, replace=replace)
1471+
if not s or not replace:
1472+
s = default
1473+
if s is None:
1474+
raise KeyError("no config value [{}] {} found".format(self.section_name, name))
1475+
1476+
if not isinstance(s, float):
1477+
try:
1478+
s = float(s)
1479+
except ValueError:
1480+
raise tox.exception.ConfigError(
1481+
"{}: invalid float {!r}".format(name, s)
1482+
)
1483+
return s
1484+
14511485
def getbool(self, name, default=None, replace=True):
14521486
s = self.getstring(name, default, replace=replace)
14531487
if not s or not replace:

src/tox/session/__init__.py

+4
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
from tox import reporter
2121
from tox.action import Action
2222
from tox.config import parseconfig
23+
from tox.config import INTERRUPT_TIMEOUT
24+
from tox.config import TERMINATE_TIMEOUT
2325
from tox.config.parallel import ENV_VAR_KEY_PRIVATE as PARALLEL_ENV_VAR_KEY_PRIVATE
2426
from tox.config.parallel import OFF_VALUE as PARALLEL_OFF
2527
from tox.logs.result import ResultLog
@@ -170,6 +172,8 @@ def newaction(self, name, msg, *args):
170172
self.resultlog.command_log,
171173
self.popen,
172174
sys.executable,
175+
INTERRUPT_TIMEOUT,
176+
TERMINATE_TIMEOUT,
173177
)
174178

175179
def runcommand(self):

src/tox/venv.py

+2
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ def new_action(self, msg, *args):
130130
command_log,
131131
self.popen,
132132
self.envconfig.envpython,
133+
self.envconfig.interrupt_timeout,
134+
self.envconfig.terminate_timeout,
133135
)
134136

135137
def get_result_json_path(self):

tests/unit/config/test_config.py

+19
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,25 @@ def test_is_same_dep(self):
175175
assert DepOption._is_same_dep("pkg_hello-world3==1.0", "pkg_hello-world3<=2.0")
176176
assert not DepOption._is_same_dep("pkg_hello-world3==1.0", "otherpkg>=2.0")
177177

178+
def test_interrupt_terminate_timeout_set_manually(self, newconfig):
179+
config = newconfig(
180+
[],
181+
"""
182+
[testenv:dev]
183+
interrupt_timeout = 5.0
184+
terminate_timeout = 10.0
185+
186+
[testenv:other]
187+
""",
188+
)
189+
envconfig = config.envconfigs["other"]
190+
assert 0.3 == envconfig.interrupt_timeout
191+
assert 0.2 == envconfig.terminate_timeout
192+
193+
envconfig = config.envconfigs["dev"]
194+
assert 5.0 == envconfig.interrupt_timeout
195+
assert 10.0 == envconfig.terminate_timeout
196+
178197

179198
class TestConfigPlatform:
180199
def test_config_parse_platform(self, newconfig):

0 commit comments

Comments
 (0)