Repository: tox-dev/tox

Issue:
kill tox process does not kill running commands
Tox seems to not being sending the sigterm signal back to the subprocess it is driving. 

This can be reproduced with this configuration, and killing the tox process.

`tox.ini`

```ini
[tox]
skipsdist = True

[testenv:papa]
commands =
    {envpython} {toxinidir}/papa.py
```

`papa.py` 

```python
from time import sleep
while True:
   sleep(1)
   print('papa')
```

from a terminal run
```
tox -e papa
```

and on another, search the process and kill it, for example:
```
diazdavi@darker:/tmp$ ps -aux | grep tox
diazdavi  779128  6.3  0.1 114196 22588 pts/0    S+   17:00   0:00 /usr/bin/python3 /usr/local/bin/tox -e papa
diazdavi  779140  0.5  0.0  26912  9500 pts/0    S+   17:00   0:00 /tmp/.tox/papa/bin/python papa.py
diazdavi  779178  0.0  0.0  17664  2700 pts/2    S+   17:00   0:00 grep --color=auto tox
diazdavi@darker:/tmp$ kill 779128
```

The first terminal will keep showing the prints :( 

Discussion:

Patch:
diff --git a/docs/changelog/1772.bugfix.rst b/docs/changelog/1772.bugfix.rst
new file mode 100644
index 00000000..c96ba979
--- /dev/null
+++ b/docs/changelog/1772.bugfix.rst
@@ -0,0 +1,2 @@
+Fix a killed tox (via SIGTERM) leaving the commands subprocesses running
+by handling it as if it were a KeyboardInterrupt - by :user:`dajose`
diff --git a/docs/config.rst b/docs/config.rst
index f3bb1b12..01bc9b7d 100644
--- a/docs/config.rst
+++ b/docs/config.rst
@@ -607,9 +607,9 @@ Complete list of settings that you can put into ``testenv*`` sections:
 
     .. versionadded:: 3.15.2
 
-    When an interrupt is sent via Ctrl+C, the SIGINT is sent to all foreground
-    processes. The :conf:``suicide_timeout`` gives the running process time to
-    cleanup and exit before receiving (in some cases, a duplicate) SIGINT from
+    When an interrupt is sent via Ctrl+C or the tox process is killed with a SIGTERM,
+    a SIGINT is sent to all foreground processes. The :conf:``suicide_timeout`` gives
+    the running process time to cleanup and exit before receiving (in some cases, a duplicate) SIGINT from
     tox.
 
 .. conf:: interrupt_timeout ^ float ^ 0.3
diff --git a/src/tox/action.py b/src/tox/action.py
index b5381b83..e7f9b77b 100644
--- a/src/tox/action.py
+++ b/src/tox/action.py
@@ -49,6 +49,10 @@ class Action(object):
         self.suicide_timeout = suicide_timeout
         self.interrupt_timeout = interrupt_timeout
         self.terminate_timeout = terminate_timeout
+        if is_main_thread():
+            # python allows only main thread to install signal handlers
+            # see https://docs.python.org/3/library/signal.html#signals-and-threads
+            self._install_sigterm_handler()
 
     def __enter__(self):
         msg = "{} {}".format(self.msg, " ".join(map(str, self.args)))
@@ -278,3 +282,12 @@ class Action(object):
             new_args.append(str(arg))
 
         return new_args
+
+    def _install_sigterm_handler(self):
+        """Handle sigterm as if it were a keyboardinterrupt"""
+
+        def sigterm_handler(signum, frame):
+            reporter.error("Got SIGTERM, handling it as a KeyboardInterrupt")
+            raise KeyboardInterrupt()
+
+        signal.signal(signal.SIGTERM, sigterm_handler)


Test patch:
diff --git a/tests/integration/test_provision_int.py b/tests/integration/test_provision_int.py
index 0ae411b8..05fb1a66 100644
--- a/tests/integration/test_provision_int.py
+++ b/tests/integration/test_provision_int.py
@@ -73,7 +73,8 @@ def test_provision_from_pyvenv(initproj, cmd, monkeypatch):
     "sys.platform == 'win32'",
     reason="triggering SIGINT reliably on Windows is hard",
 )
-def test_provision_interrupt_child(initproj, monkeypatch, capfd):
+@pytest.mark.parametrize("signal_type", [signal.SIGINT, signal.SIGTERM])
+def test_provision_interrupt_child(initproj, monkeypatch, capfd, signal_type):
     monkeypatch.delenv(str("PYTHONPATH"), raising=False)
     monkeypatch.setenv(str("TOX_REPORTER_TIMESTAMP"), str("1"))
     initproj(
@@ -123,7 +124,7 @@ def test_provision_interrupt_child(initproj, monkeypatch, capfd):
         # 1 process for the host tox, 1 for the provisioned
         assert len(all_process) >= 2, all_process
 
-    process.send_signal(signal.CTRL_C_EVENT if sys.platform == "win32" else signal.SIGINT)
+    process.send_signal(signal.CTRL_C_EVENT if sys.platform == "win32" else signal_type)
     process.communicate()
     out, err = capfd.readouterr()
     assert ".tox KeyboardInterrupt: from" in out, out