Skip to content

Commit eea285d

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 eea285d

File tree

8 files changed

+100
-9
lines changed

8 files changed

+100
-9
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/1493.feature.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add ``interrupt_timeout`` and ``terminate_timeout`` that configure delay between SIGINT, SIGTERM and SIGKILL when tox is interrupted. - by :user:`sileht`

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 :conf:``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 :conf:``interrupt_timeout`` seconds, sends it a SIGTERM, wait
177+
:conf:``terminate_timeout`` seconds, and sends it a SIGKILL if it haven't exited.
163178

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

src/tox/action.py

+18-7
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,23 @@
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__(
26+
self,
27+
name,
28+
msg,
29+
args,
30+
log_dir,
31+
generate_tox_log,
32+
command_log,
33+
popen,
34+
python,
35+
interrupt_timeout,
36+
terminate_timeout,
37+
):
2938
self.name = name
3039
self.args = args
3140
self.msg = msg
@@ -36,6 +45,8 @@ def __init__(self, name, msg, args, log_dir, generate_tox_log, command_log, pope
3645
self.command_log = command_log
3746
self._timed_report = None
3847
self.python = python
48+
self.interrupt_timeout = interrupt_timeout
49+
self.terminate_timeout = terminate_timeout
3950

4051
def __enter__(self):
4152
msg = "{} {}".format(self.msg, " ".join(map(str, self.args)))
@@ -180,10 +191,10 @@ def handle_interrupt(self, process):
180191
if process.poll() is None:
181192
self.info("KeyboardInterrupt", msg.format("SIGINT"))
182193
process.send_signal(signal.CTRL_C_EVENT if sys.platform == "win32" else signal.SIGINT)
183-
if self._wait(process, WAIT_INTERRUPT) is None:
194+
if self._wait(process, self.interrupt_timeout) is None:
184195
self.info("KeyboardInterrupt", msg.format("SIGTERM"))
185196
process.terminate()
186-
if self._wait(process, WAIT_TERMINATE) is None:
197+
if self._wait(process, self.terminate_timeout) is None:
187198
self.info("KeyboardInterrupt", msg.format("SIGKILL"))
188199
process.kill()
189200
process.communicate()
@@ -193,7 +204,7 @@ def _wait(process, timeout):
193204
if sys.version_info >= (3, 3):
194205
# python 3 has timeout feature built-in
195206
try:
196-
process.communicate(timeout=WAIT_INTERRUPT)
207+
process.communicate(timeout=timeout)
197208
except subprocess.TimeoutExpired:
198209
pass
199210
else:

src/tox/config/__init__.py

+41-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,16 @@ 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 (
1252+
"bool",
1253+
"float",
1254+
"path",
1255+
"string",
1256+
"dict",
1257+
"dict_setenv",
1258+
"argv",
1259+
"argvlist",
1260+
):
12351261
meth = getattr(reader, "get{}".format(atype))
12361262
res = meth(env_attr.name, env_attr.default, replace=replace)
12371263
elif atype == "basepython":
@@ -1448,6 +1474,20 @@ def _getdict(self, value, default, sep, replace=True):
14481474

14491475
return d
14501476

1477+
def getfloat(self, name, default=None, replace=True):
1478+
s = self.getstring(name, default, replace=replace)
1479+
if not s or not replace:
1480+
s = default
1481+
if s is None:
1482+
raise KeyError("no config value [{}] {} found".format(self.section_name, name))
1483+
1484+
if not isinstance(s, float):
1485+
try:
1486+
s = float(s)
1487+
except ValueError:
1488+
raise tox.exception.ConfigError("{}: invalid float {!r}".format(name, s))
1489+
return s
1490+
14511491
def getbool(self, name, default=None, replace=True):
14521492
s = self.getstring(name, default, replace=replace)
14531493
if not s or not replace:

src/tox/session/__init__.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import tox
2020
from tox import reporter
2121
from tox.action import Action
22-
from tox.config import parseconfig
22+
from tox.config import INTERRUPT_TIMEOUT, TERMINATE_TIMEOUT, parseconfig
2323
from tox.config.parallel import ENV_VAR_KEY_PRIVATE as PARALLEL_ENV_VAR_KEY_PRIVATE
2424
from tox.config.parallel import OFF_VALUE as PARALLEL_OFF
2525
from tox.logs.result import ResultLog
@@ -170,6 +170,8 @@ def newaction(self, name, msg, *args):
170170
self.resultlog.command_log,
171171
self.popen,
172172
sys.executable,
173+
INTERRUPT_TIMEOUT,
174+
TERMINATE_TIMEOUT,
173175
)
174176

175177
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)