Skip to content

Commit

Permalink
fix: save data on SIGTERM #1307
Browse files Browse the repository at this point in the history
This covers multiprocessing.Process.terminate(), and maybe other cases also.
  • Loading branch information
nedbat committed Jan 23, 2022
1 parent 2e8c191 commit dd575ee
Show file tree
Hide file tree
Showing 6 changed files with 119 additions and 17 deletions.
7 changes: 7 additions & 0 deletions CHANGES.rst
Expand Up @@ -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`_.
Expand All @@ -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:
Expand Down
19 changes: 17 additions & 2 deletions coverage/control.py
Expand Up @@ -9,6 +9,7 @@
import os
import os.path
import platform
import signal
import sys
import time
import warnings
Expand Down Expand Up @@ -228,6 +229,7 @@ def __init__(
self._exclude_re = None
self._debug = None
self._file_mapper = None
self._old_sigterm = None

# State machine variables:
# Have we initialized everything?
Expand Down Expand Up @@ -526,6 +528,11 @@ def _init_for_start(self):
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."""
Expand Down Expand Up @@ -583,15 +590,23 @@ def stop(self):
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.
Expand Down
2 changes: 1 addition & 1 deletion coverage/multiproc.py
Expand Up @@ -27,7 +27,7 @@ def _bootstrap(self, *args, **kwargs):
"""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
Expand Down
2 changes: 2 additions & 0 deletions doc/config.rst
Expand Up @@ -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
Expand Down
27 changes: 13 additions & 14 deletions doc/subprocess.rst
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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.
79 changes: 79 additions & 0 deletions tests/test_concurrency.py
Expand Up @@ -693,3 +693,82 @@ def random_load(): # pragma: nested
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"

0 comments on commit dd575ee

Please sign in to comment.