diff options
author | Ned Batchelder <ned@nedbatchelder.com> | 2022-01-23 07:15:08 -0500 |
---|---|---|
committer | Ned Batchelder <ned@nedbatchelder.com> | 2022-01-23 13:44:58 -0500 |
commit | dd575eec0fa173bcb6c680d2f1c822a28280d011 (patch) | |
tree | 44992b89c286d9c8bb86478f2e70c03616e54a11 | |
parent | 2e8c1910cad1ba23726e62e03c4ae1608f3fb26e (diff) | |
download | python-coveragepy-git-dd575eec0fa173bcb6c680d2f1c822a28280d011.tar.gz |
fix: save data on SIGTERM #1307
This covers multiprocessing.Process.terminate(), and maybe other cases also.
-rw-r--r-- | CHANGES.rst | 7 | ||||
-rw-r--r-- | coverage/control.py | 19 | ||||
-rw-r--r-- | coverage/multiproc.py | 2 | ||||
-rw-r--r-- | doc/config.rst | 2 | ||||
-rw-r--r-- | doc/subprocess.rst | 27 | ||||
-rw-r--r-- | tests/test_concurrency.py | 79 |
6 files changed, 119 insertions, 17 deletions
diff --git a/CHANGES.rst b/CHANGES.rst index e9ae1d6e..dbeaca2c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -25,6 +25,12 @@ Unreleased - Feature: Added the `lcov` command to generate reports in LCOV format. Thanks, Bradley Burns. Closes `issue 587`_ and `issue 626`_. +- Feature: coverage measurement data will now be written when a SIGTERM signal + is received by the process. This includes + :meth:`Process.terminate <python:multiprocessing.Process.terminate>`, + and other ways to terminate a process. Currently this is only on Linux and + Mac; Windows is not supported. Fixes `issue 1307`_. + - Dropped support for Python 3.6, which reached end-of-life on 2021-12-23. - Updated Python 3.11 support to 3.11.0a4, fixing `issue 1294`_. @@ -45,6 +51,7 @@ Unreleased .. _issue 1288: https://github.com/nedbat/coveragepy/issues/1288 .. _issue 1294: https://github.com/nedbat/coveragepy/issues/1294 .. _issue 1303: https://github.com/nedbat/coveragepy/issues/1303 +.. _issue 1307: https://github.com/nedbat/coveragepy/issues/1307 .. _changes_62: diff --git a/coverage/control.py b/coverage/control.py index bd51ffc5..913d6893 100644 --- a/coverage/control.py +++ b/coverage/control.py @@ -9,6 +9,7 @@ import contextlib import os import os.path import platform +import signal import sys import time import warnings @@ -228,6 +229,7 @@ class Coverage: self._exclude_re = None self._debug = None self._file_mapper = None + self._old_sigterm = None # State machine variables: # Have we initialized everything? @@ -526,6 +528,11 @@ class Coverage: self._should_write_debug = True atexit.register(self._atexit) + if not env.WINDOWS: + # The Python docs seem to imply that SIGTERM works uniformly even + # on Windows, but that's not my experience, and this agrees: + # https://stackoverflow.com/questions/35772001/x/35792192#35792192 + self._old_sigterm = signal.signal(signal.SIGTERM, self._on_sigterm) def _init_data(self, suffix): """Create a data file if we don't have one yet.""" @@ -583,15 +590,23 @@ class Coverage: self._collector.stop() self._started = False - def _atexit(self): + def _atexit(self, event="atexit"): """Clean up on process shutdown.""" if self._debug.should("process"): - self._debug.write(f"atexit: pid: {os.getpid()}, instance: {self!r}") + self._debug.write(f"{event}: pid: {os.getpid()}, instance: {self!r}") if self._started: self.stop() if self._auto_save: self.save() + def _on_sigterm(self, signum_unused, frame_unused): + """A handler for signal.SIGTERM.""" + self._atexit("sigterm") + # Statements after here won't be seen by metacov because we just wrote + # the data, and are about to kill the process. + signal.signal(signal.SIGTERM, self._old_sigterm) # pragma: not covered + os.kill(os.getpid(), signal.SIGTERM) # pragma: not covered + def erase(self): """Erase previously collected coverage data. diff --git a/coverage/multiproc.py b/coverage/multiproc.py index 1f9225f3..3a9bd633 100644 --- a/coverage/multiproc.py +++ b/coverage/multiproc.py @@ -27,7 +27,7 @@ class ProcessWithCoverage(OriginalProcess): # pylint: disable=abstract-m """Wrapper around _bootstrap to start coverage.""" try: from coverage import Coverage # avoid circular import - cov = Coverage(data_suffix=True) + cov = Coverage(data_suffix=True, auto_data=True) cov._warn_preimported_source = False cov.start() debug = cov._debug diff --git a/doc/config.rst b/doc/config.rst index e44390e5..b82df993 100644 --- a/doc/config.rst +++ b/doc/config.rst @@ -132,6 +132,8 @@ option, or coverage.py will produce very wrong results. .. _gevent: http://www.gevent.org/ .. _eventlet: http://eventlet.net/ +See :ref:subprocess: for details of multi-process measurement. + Before version 4.2, this option only accepted a single string. .. versionadded:: 4.0 diff --git a/doc/subprocess.rst b/doc/subprocess.rst index b28cbc91..777ffbae 100644 --- a/doc/subprocess.rst +++ b/doc/subprocess.rst @@ -25,7 +25,7 @@ the name of the :ref:`configuration file <config>` to use. .. note:: - If you have subprocesses because you are using :mod:`multiprocessing + If you have subprocesses created with :mod:`multiprocessing <python:multiprocessing>`, the ``--concurrency=multiprocessing`` command-line option should take care of everything for you. See :ref:`cmd_run` for details. @@ -34,8 +34,8 @@ When using this technique, be sure to set the parallel option to true so that multiple coverage.py runs will each write their data to a distinct file. -Configuring Python for sub-process coverage -------------------------------------------- +Configuring Python for sub-process measurement +---------------------------------------------- Measuring coverage in sub-processes is a little tricky. When you spawn a sub-process, you are invoking Python to run your program. Usually, to get @@ -84,18 +84,17 @@ start-up. Be sure to remove the change when you uninstall coverage.py, or use a more defensive approach to importing it. -Signal handlers and atexit --------------------------- - -.. hmm, this isn't specifically about subprocesses, is there a better place - where we could talk about this? +Process termination +------------------- To successfully write a coverage data file, the Python sub-process under -analysis must shut down cleanly and have a chance for coverage.py to run the -``atexit`` handler it registers. +analysis must shut down cleanly and have a chance for coverage.py to run its +termination code. It will do that when the process ends naturally, or when a +SIGTERM signal is received. -For example if you send SIGTERM to end the sub-process, but your sub-process -has never registered any SIGTERM handler, then a coverage file won't be -written. See the `atexit`_ docs for details of when the handler isn't run. +Coverage.py uses :mod:`atexit <python:atexit>` to handle usual process ends, +and a :mod:`signal <python:signal>` handler to catch SIGTERM signals. -.. _atexit: https://docs.python.org/3/library/atexit.html +Other ways of ending a process, like SIGKILL or :func:`os._exit +<python:os._exit>`, will prevent coverage.py from writing its data file, +leaving you with incomplete or non-existent coverage data. diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py index 001455f2..14dfc5ca 100644 --- a/tests/test_concurrency.py +++ b/tests/test_concurrency.py @@ -693,3 +693,82 @@ def test_thread_safe_save_data(tmpdir): finally: os.chdir(old_dir) should_run[0] = False + + +@pytest.mark.skipif(env.WINDOWS, reason="SIGTERM doesn't work the same on Windows") +class SigtermTest(CoverageTest): + """Tests of our handling of SIGTERM.""" + + def test_sigterm_saves_data(self): + # A terminated process should save its coverage data. + self.make_file("clobbered.py", """\ + import multiprocessing + import time + + def subproc(x): + if x.value == 3: + print("THREE", flush=True) # line 6, missed + else: + print("NOT THREE", flush=True) + x.value = 0 + time.sleep(60) + + if __name__ == "__main__": + print("START", flush=True) + x = multiprocessing.Value("L", 1) + proc = multiprocessing.Process(target=subproc, args=(x,)) + proc.start() + while x.value != 0: + time.sleep(.05) + proc.terminate() + print("END", flush=True) + """) + self.make_file(".coveragerc", """\ + [run] + parallel = True + concurrency = multiprocessing + """) + out = self.run_command("coverage run clobbered.py") + # Under the Python tracer on Linux, we get the "Trace function changed" + # message. Does that matter? + if "Trace function changed" in out: + lines = out.splitlines(True) + assert len(lines) == 5 # "trace function changed" and "self.warn(" + out = "".join(lines[:3]) + assert out == "START\nNOT THREE\nEND\n" + self.run_command("coverage combine") + out = self.run_command("coverage report -m") + assert self.squeezed_lines(out)[2] == "clobbered.py 17 1 94% 6" + + def test_sigterm_still_runs(self): + # A terminated process still runs its own SIGTERM handler. + self.make_file("handler.py", """\ + import multiprocessing + import signal + import time + + def subproc(x): + print("START", flush=True) + def on_sigterm(signum, frame): + print("SIGTERM", flush=True) + + signal.signal(signal.SIGTERM, on_sigterm) + x.value = 0 + time.sleep(.1) + print("END", flush=True) + + if __name__ == "__main__": + x = multiprocessing.Value("L", 1) + proc = multiprocessing.Process(target=subproc, args=(x,)) + proc.start() + while x.value != 0: + time.sleep(.02) + proc.terminate() + """) + self.make_file(".coveragerc", """\ + [run] + parallel = True + concurrency = multiprocessing + """) + out = self.run_command("coverage run handler.py") + assert out == "START\nSIGTERM\nEND\n" |