Skip to content

Commit 41eea2e

Browse files
authored
Merge pull request #265 from pytest-dev/remove-mp-finalizer
Remove multiprocess finalizer and improve subprocess docs
2 parents 2bfc0f0 + 42f0307 commit 41eea2e

10 files changed

Lines changed: 431 additions & 119 deletions

File tree

appveyor.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ environment:
88
- TOXENV: 'py27-t310-c45,py27-t40-c45,py27-t41-c45'
99
- TOXENV: 'py34-t310-c45,py34-t40-c45,py34-t41-c45'
1010
- TOXENV: 'py35-t310-c45,py35-t40-c45,py35-t41-c45'
11-
- TOXENV: 'pypy-t310-c45,pypy-t40-c45,pypy-t41-c45,pypy3-t310-c45,pypy3-t40-c45,pypy3-t41-c45'
11+
- TOXENV: 'pypy-t310-c45,pypy-t40-c45,pypy-t41-c45'
1212

1313
init:
1414
- ps: echo $env:TOXENV

ci/bootstrap.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252

5353
template_vars = {'tox_environments': tox_environments}
5454
for py_ver in '27 34 35 py'.split():
55-
template_vars['py%s_environments' % py_ver] = [x for x in tox_environments if x.startswith('py' + py_ver)]
55+
template_vars['py%s_environments' % py_ver] = [x for x in tox_environments if x.startswith('py' + py_ver + '-')]
5656

5757
for name in os.listdir(join("ci", "templates")):
5858
with open(join(base_path, name), "w") as fh:

docs/index.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Contents:
1111
reporting
1212
debuggers
1313
xdist
14-
mp
14+
subprocess-support
1515
plugins
1616
markers-fixtures
1717
changelog

docs/mp.rst

Lines changed: 0 additions & 70 deletions
This file was deleted.

docs/plugins.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ Alternatively you can have this in ``tox.ini`` (if you're using `Tox <https://to
1414

1515
[testenv]
1616
setenv =
17-
COV_CORE_SOURCE={toxinidir}/src
17+
COV_CORE_SOURCE=
1818
COV_CORE_CONFIG={toxinidir}/.coveragerc
19-
COV_CORE_DATAFILE={toxinidir}/.coverage.eager
19+
COV_CORE_DATAFILE={toxinidir}/.coverage
2020

2121
And in ``pytest.ini`` / ``tox.ini`` / ``setup.cfg``::
2222

docs/subprocess-support.rst

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
==================
2+
Subprocess support
3+
==================
4+
5+
Normally coverage writes the data via a pretty standard atexit handler. However, if the subprocess doesn't exit on its
6+
own then the atexit handler might not run. Why that happens is best left to the adventurous to discover by waddling
7+
though the Python bug tracker.
8+
9+
pytest-cov supports subprocesses and multiprocessing, and works around these atexit limitations. However, there are a
10+
few pitfalls that need to be explained.
11+
12+
If you use ``multiprocessing.Pool``
13+
===================================
14+
15+
**pytest-cov** automatically registers a multiprocessing finalizer. The finalizer will only run reliably if the pool is
16+
closed. Closing the pool basically signals the workers that there will be no more work, and they will eventually exit.
17+
Thus one also needs to call `join` on the pool.
18+
19+
If you use ``multiprocessing.Pool.terminate`` or the context manager API (``__exit__``
20+
will just call ``terminate``) then the workers can get SIGTERM and then the finalizers won't run or complete in time.
21+
Thus you need to make sure your ``multiprocessing.Pool`` gets a nice and clean exit:
22+
23+
.. code-block:: python
24+
25+
from multiprocessing import Pool
26+
27+
def f(x):
28+
return x*x
29+
30+
if __name__ == '__main__':
31+
p = Pool(5)
32+
try:
33+
print(p.map(f, [1, 2, 3]))
34+
finally:
35+
p.close() # Marks the pool as closed.
36+
p.join() # Waits for workers to exit.
37+
38+
39+
If you must use the context manager API (e.g.: the pool is managed in third party code you can't change) then you can
40+
register a cleaning SIGTERM handler like so:
41+
42+
.. code-block:: python
43+
44+
from multiprocessing import Pool
45+
46+
def f(x):
47+
return x*x
48+
49+
if __name__ == '__main__':
50+
try:
51+
from pytest_cov.embed import cleanup_on_sigterm
52+
except ImportError:
53+
pass
54+
else:
55+
cleanup_on_sigterm()
56+
57+
with Pool(5) as p:
58+
print(p.map(f, [1, 2, 3]))
59+
60+
If you use ``multiprocessing.Process``
61+
======================================
62+
63+
There's similar issue when using the ``Process`` objects. Don't forget to use ``.join()``:
64+
65+
.. code-block:: python
66+
67+
from multiprocessing import Process
68+
69+
def f(name):
70+
print('hello', name)
71+
72+
if __name__ == '__main__':
73+
try:
74+
from pytest_cov.embed import cleanup_on_sigterm
75+
except ImportError:
76+
pass
77+
else:
78+
cleanup_on_sigterm()
79+
80+
p = Process(target=f, args=('bob',))
81+
try:
82+
p.start()
83+
finally:
84+
p.join() # necessary so that the Process exists before the test suite exits (thus coverage is collected)
85+
86+
.. _cleanup_on_sigterm:
87+
88+
If you got custom signal handling
89+
=================================
90+
91+
**pytest-cov 2.6** has a rudimentary ``pytest_cov.embed.cleanup_on_sigterm`` you can use to register a SIGTERM handler
92+
that flushes the coverage data.
93+
94+
**pytest-cov 2.7** adds a ``pytest_cov.embed.cleanup_on_signal`` function and changes the implementation to be more
95+
robust: the handler will call the previous handler (if you had previously registered any), and is re-entrant (will
96+
defer extra signals if delivered while the handler runs).
97+
98+
For example, if you reload on SIGHUP you should have something like this:
99+
100+
.. code-block:: python
101+
102+
import os
103+
import signal
104+
105+
def restart_service(frame, signum):
106+
os.exec( ... ) # or whatever your custom signal would do
107+
signal.signal(signal.SIGHUP, restart_service)
108+
109+
try:
110+
from pytest_cov.embed import cleanup_on_signal
111+
except ImportError:
112+
pass
113+
else:
114+
cleanup_on_signal(signal.SIGHUP)
115+
116+
Note that both ``cleanup_on_signal`` and ``cleanup_on_sigterm`` will run the previous signal handler.
117+
118+
Alternatively you can do this:
119+
120+
.. code-block:: python
121+
122+
import os
123+
import signal
124+
125+
try:
126+
from pytest_cov.embed import cleanup
127+
except ImportError:
128+
cleanup = None
129+
130+
def restart_service(frame, signum):
131+
if cleanup is not None:
132+
cleanup()
133+
134+
os.exec( ... ) # or whatever your custom signal would do
135+
signal.signal(signal.SIGHUP, restart_service)
136+
137+
If you use Windows
138+
==================
139+
140+
On Windows you can register a handler for SIGTERM but it doesn't actually work. It will work if you
141+
`os.kill(os.getpid(), signal.SIGTERM)` (send SIGTERM to the current process) but for most intents and purposes that's
142+
completely useless.
143+
144+
Consequently this means that if you use multiprocessing you got no choice but to use the close/join pattern as described
145+
above. Using the context manager API or `terminate` won't work as it relies on SIGTERM.
146+
147+
However you can have a working handler for SIGBREAK (with some caveats):
148+
149+
.. code-block:: python
150+
151+
import os
152+
import signal
153+
154+
def shutdown(frame, signum):
155+
# your app's shutdown or whatever
156+
signal.signal(signal.SIGBREAK, shutdown)
157+
158+
try:
159+
from pytest_cov.embed import cleanup_on_signal
160+
except ImportError:
161+
pass
162+
else:
163+
cleanup_on_signal(signal.SIGBREAK)
164+
165+
The `caveats <https://stefan.sofa-rockers.org/2013/08/15/handling-sub-process-hierarchies-python-linux-os-x/>`_ being
166+
roughly:
167+
168+
* you need to deliver ``signal.CTRL_BREAK_EVENT``
169+
* it gets delivered to the whole process group, and that can have unforeseen consequences

src/pytest_cov/embed.py

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,19 @@
1313
that code coverage is being collected we activate coverage based on
1414
info passed via env vars.
1515
"""
16+
import atexit
1617
import os
1718
import signal
1819

19-
active_cov = None
20+
_active_cov = None
2021

2122

2223
def multiprocessing_start(_):
24+
global _active_cov
2325
cov = init()
2426
if cov:
25-
multiprocessing.util.Finalize(None, cleanup, args=(cov,), exitpriority=1000)
27+
_active_cov = cov
28+
multiprocessing.util.Finalize(None, cleanup, exitpriority=1000)
2629

2730

2831
try:
@@ -36,27 +39,29 @@ def multiprocessing_start(_):
3639
def init():
3740
# Only continue if ancestor process has set everything needed in
3841
# the env.
39-
global active_cov
42+
global _active_cov
4043

4144
cov_source = os.environ.get('COV_CORE_SOURCE')
4245
cov_config = os.environ.get('COV_CORE_CONFIG')
4346
cov_datafile = os.environ.get('COV_CORE_DATAFILE')
4447
cov_branch = True if os.environ.get('COV_CORE_BRANCH') == 'enabled' else None
4548

4649
if cov_datafile:
50+
if _active_cov:
51+
cleanup()
4752
# Import what we need to activate coverage.
4853
import coverage
4954

5055
# Determine all source roots.
51-
if cov_source == os.pathsep:
56+
if cov_source in os.pathsep:
5257
cov_source = None
5358
else:
5459
cov_source = cov_source.split(os.pathsep)
5560
if cov_config == os.pathsep:
5661
cov_config = True
5762

5863
# Activate coverage for this process.
59-
cov = active_cov = coverage.Coverage(
64+
cov = _active_cov = coverage.Coverage(
6065
source=cov_source,
6166
branch=cov_branch,
6267
data_suffix=True,
@@ -75,28 +80,44 @@ def _cleanup(cov):
7580
if cov is not None:
7681
cov.stop()
7782
cov.save()
83+
cov._auto_save = False # prevent autosaving from cov._atexit in case the interpreter lacks atexit.unregister
84+
try:
85+
atexit.unregister(cov._atexit)
86+
except Exception:
87+
pass
7888

7989

80-
def cleanup(cov=None):
81-
global active_cov
90+
def cleanup():
91+
global _active_cov
92+
global _cleanup_in_progress
93+
global _pending_signal
8294

83-
_cleanup(cov)
84-
if active_cov is not cov:
85-
_cleanup(active_cov)
86-
active_cov = None
95+
_cleanup_in_progress = True
96+
_cleanup(_active_cov)
97+
_active_cov = None
98+
_cleanup_in_progress = False
99+
if _pending_signal:
100+
_signal_cleanup_handler(*_pending_signal)
101+
_pending_signal = None
87102

88103

89104
multiprocessing_finish = cleanup # in case someone dared to use this internal
90105

91106
_previous_handlers = {}
107+
_pending_signal = None
108+
_cleanup_in_progress = False
92109

93110

94111
def _signal_cleanup_handler(signum, frame):
112+
global _pending_signal
113+
if _cleanup_in_progress:
114+
_pending_signal = signum, frame
115+
return
95116
cleanup()
96117
_previous_handler = _previous_handlers.get(signum)
97118
if _previous_handler == signal.SIG_IGN:
98119
return
99-
elif _previous_handler:
120+
elif _previous_handler and _previous_handler is not _signal_cleanup_handler:
100121
_previous_handler(signum, frame)
101122
elif signum == signal.SIGTERM:
102123
os._exit(128 + signum)
@@ -105,8 +126,10 @@ def _signal_cleanup_handler(signum, frame):
105126

106127

107128
def cleanup_on_signal(signum):
108-
_previous_handlers[signum] = signal.getsignal(signum)
109-
signal.signal(signum, _signal_cleanup_handler)
129+
previous = signal.getsignal(signum)
130+
if previous is not _signal_cleanup_handler:
131+
_previous_handlers[signum] = previous
132+
signal.signal(signum, _signal_cleanup_handler)
110133

111134

112135
def cleanup_on_sigterm():

0 commit comments

Comments
 (0)