Skip to content

Commit dd575ee

Browse files
committed
fix: save data on SIGTERM #1307
This covers multiprocessing.Process.terminate(), and maybe other cases also.
1 parent 2e8c191 commit dd575ee

File tree

6 files changed

+119
-17
lines changed

6 files changed

+119
-17
lines changed

CHANGES.rst

+7
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ Unreleased
2525
- Feature: Added the `lcov` command to generate reports in LCOV format.
2626
Thanks, Bradley Burns. Closes `issue 587`_ and `issue 626`_.
2727

28+
- Feature: coverage measurement data will now be written when a SIGTERM signal
29+
is received by the process. This includes
30+
:meth:`Process.terminate <python:multiprocessing.Process.terminate>`,
31+
and other ways to terminate a process. Currently this is only on Linux and
32+
Mac; Windows is not supported. Fixes `issue 1307`_.
33+
2834
- Dropped support for Python 3.6, which reached end-of-life on 2021-12-23.
2935

3036
- Updated Python 3.11 support to 3.11.0a4, fixing `issue 1294`_.
@@ -45,6 +51,7 @@ Unreleased
4551
.. _issue 1288: https://github.com/nedbat/coveragepy/issues/1288
4652
.. _issue 1294: https://github.com/nedbat/coveragepy/issues/1294
4753
.. _issue 1303: https://github.com/nedbat/coveragepy/issues/1303
54+
.. _issue 1307: https://github.com/nedbat/coveragepy/issues/1307
4855

4956

5057
.. _changes_62:

coverage/control.py

+17-2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import os
1010
import os.path
1111
import platform
12+
import signal
1213
import sys
1314
import time
1415
import warnings
@@ -228,6 +229,7 @@ def __init__(
228229
self._exclude_re = None
229230
self._debug = None
230231
self._file_mapper = None
232+
self._old_sigterm = None
231233

232234
# State machine variables:
233235
# Have we initialized everything?
@@ -526,6 +528,11 @@ def _init_for_start(self):
526528
self._should_write_debug = True
527529

528530
atexit.register(self._atexit)
531+
if not env.WINDOWS:
532+
# The Python docs seem to imply that SIGTERM works uniformly even
533+
# on Windows, but that's not my experience, and this agrees:
534+
# https://stackoverflow.com/questions/35772001/x/35792192#35792192
535+
self._old_sigterm = signal.signal(signal.SIGTERM, self._on_sigterm)
529536

530537
def _init_data(self, suffix):
531538
"""Create a data file if we don't have one yet."""
@@ -583,15 +590,23 @@ def stop(self):
583590
self._collector.stop()
584591
self._started = False
585592

586-
def _atexit(self):
593+
def _atexit(self, event="atexit"):
587594
"""Clean up on process shutdown."""
588595
if self._debug.should("process"):
589-
self._debug.write(f"atexit: pid: {os.getpid()}, instance: {self!r}")
596+
self._debug.write(f"{event}: pid: {os.getpid()}, instance: {self!r}")
590597
if self._started:
591598
self.stop()
592599
if self._auto_save:
593600
self.save()
594601

602+
def _on_sigterm(self, signum_unused, frame_unused):
603+
"""A handler for signal.SIGTERM."""
604+
self._atexit("sigterm")
605+
# Statements after here won't be seen by metacov because we just wrote
606+
# the data, and are about to kill the process.
607+
signal.signal(signal.SIGTERM, self._old_sigterm) # pragma: not covered
608+
os.kill(os.getpid(), signal.SIGTERM) # pragma: not covered
609+
595610
def erase(self):
596611
"""Erase previously collected coverage data.
597612

coverage/multiproc.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def _bootstrap(self, *args, **kwargs):
2727
"""Wrapper around _bootstrap to start coverage."""
2828
try:
2929
from coverage import Coverage # avoid circular import
30-
cov = Coverage(data_suffix=True)
30+
cov = Coverage(data_suffix=True, auto_data=True)
3131
cov._warn_preimported_source = False
3232
cov.start()
3333
debug = cov._debug

doc/config.rst

+2
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ option, or coverage.py will produce very wrong results.
132132
.. _gevent: http://www.gevent.org/
133133
.. _eventlet: http://eventlet.net/
134134

135+
See :ref:subprocess: for details of multi-process measurement.
136+
135137
Before version 4.2, this option only accepted a single string.
136138

137139
.. versionadded:: 4.0

doc/subprocess.rst

+13-14
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ the name of the :ref:`configuration file <config>` to use.
2525

2626
.. note::
2727

28-
If you have subprocesses because you are using :mod:`multiprocessing
28+
If you have subprocesses created with :mod:`multiprocessing
2929
<python:multiprocessing>`, the ``--concurrency=multiprocessing``
3030
command-line option should take care of everything for you. See
3131
:ref:`cmd_run` for details.
@@ -34,8 +34,8 @@ When using this technique, be sure to set the parallel option to true so that
3434
multiple coverage.py runs will each write their data to a distinct file.
3535

3636

37-
Configuring Python for sub-process coverage
38-
-------------------------------------------
37+
Configuring Python for sub-process measurement
38+
----------------------------------------------
3939

4040
Measuring coverage in sub-processes is a little tricky. When you spawn a
4141
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
8484
a more defensive approach to importing it.
8585

8686

87-
Signal handlers and atexit
88-
--------------------------
89-
90-
.. hmm, this isn't specifically about subprocesses, is there a better place
91-
where we could talk about this?
87+
Process termination
88+
-------------------
9289

9390
To successfully write a coverage data file, the Python sub-process under
94-
analysis must shut down cleanly and have a chance for coverage.py to run the
95-
``atexit`` handler it registers.
91+
analysis must shut down cleanly and have a chance for coverage.py to run its
92+
termination code. It will do that when the process ends naturally, or when a
93+
SIGTERM signal is received.
9694

97-
For example if you send SIGTERM to end the sub-process, but your sub-process
98-
has never registered any SIGTERM handler, then a coverage file won't be
99-
written. See the `atexit`_ docs for details of when the handler isn't run.
95+
Coverage.py uses :mod:`atexit <python:atexit>` to handle usual process ends,
96+
and a :mod:`signal <python:signal>` handler to catch SIGTERM signals.
10097

101-
.. _atexit: https://docs.python.org/3/library/atexit.html
98+
Other ways of ending a process, like SIGKILL or :func:`os._exit
99+
<python:os._exit>`, will prevent coverage.py from writing its data file,
100+
leaving you with incomplete or non-existent coverage data.

tests/test_concurrency.py

+79
Original file line numberDiff line numberDiff line change
@@ -693,3 +693,82 @@ def random_load(): # pragma: nested
693693
finally:
694694
os.chdir(old_dir)
695695
should_run[0] = False
696+
697+
698+
@pytest.mark.skipif(env.WINDOWS, reason="SIGTERM doesn't work the same on Windows")
699+
class SigtermTest(CoverageTest):
700+
"""Tests of our handling of SIGTERM."""
701+
702+
def test_sigterm_saves_data(self):
703+
# A terminated process should save its coverage data.
704+
self.make_file("clobbered.py", """\
705+
import multiprocessing
706+
import time
707+
708+
def subproc(x):
709+
if x.value == 3:
710+
print("THREE", flush=True) # line 6, missed
711+
else:
712+
print("NOT THREE", flush=True)
713+
x.value = 0
714+
time.sleep(60)
715+
716+
if __name__ == "__main__":
717+
print("START", flush=True)
718+
x = multiprocessing.Value("L", 1)
719+
proc = multiprocessing.Process(target=subproc, args=(x,))
720+
proc.start()
721+
while x.value != 0:
722+
time.sleep(.05)
723+
proc.terminate()
724+
print("END", flush=True)
725+
""")
726+
self.make_file(".coveragerc", """\
727+
[run]
728+
parallel = True
729+
concurrency = multiprocessing
730+
""")
731+
out = self.run_command("coverage run clobbered.py")
732+
# Under the Python tracer on Linux, we get the "Trace function changed"
733+
# message. Does that matter?
734+
if "Trace function changed" in out:
735+
lines = out.splitlines(True)
736+
assert len(lines) == 5 # "trace function changed" and "self.warn("
737+
out = "".join(lines[:3])
738+
assert out == "START\nNOT THREE\nEND\n"
739+
self.run_command("coverage combine")
740+
out = self.run_command("coverage report -m")
741+
assert self.squeezed_lines(out)[2] == "clobbered.py 17 1 94% 6"
742+
743+
def test_sigterm_still_runs(self):
744+
# A terminated process still runs its own SIGTERM handler.
745+
self.make_file("handler.py", """\
746+
import multiprocessing
747+
import signal
748+
import time
749+
750+
def subproc(x):
751+
print("START", flush=True)
752+
def on_sigterm(signum, frame):
753+
print("SIGTERM", flush=True)
754+
755+
signal.signal(signal.SIGTERM, on_sigterm)
756+
x.value = 0
757+
time.sleep(.1)
758+
print("END", flush=True)
759+
760+
if __name__ == "__main__":
761+
x = multiprocessing.Value("L", 1)
762+
proc = multiprocessing.Process(target=subproc, args=(x,))
763+
proc.start()
764+
while x.value != 0:
765+
time.sleep(.02)
766+
proc.terminate()
767+
""")
768+
self.make_file(".coveragerc", """\
769+
[run]
770+
parallel = True
771+
concurrency = multiprocessing
772+
""")
773+
out = self.run_command("coverage run handler.py")
774+
assert out == "START\nSIGTERM\nEND\n"

0 commit comments

Comments
 (0)