Skip to content

Commit 44a309f

Browse files
committed
feat: patch os._exit #310
1 parent 9a97393 commit 44a309f

File tree

7 files changed

+110
-4
lines changed

7 files changed

+110
-4
lines changed

CHANGES.rst

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,22 @@ upgrading your version of coverage.py.
2323
Unreleased
2424
----------
2525

26-
- Two new exclusion patterns are part of the defaults: `...` is automatically
27-
excluded as a line and `if TYPE_CHECKING` is excluded as a branch. Closes
26+
- A new configuration option: ":ref:`[run] patch <config_run_patch>`" lets you
27+
specify named patches to apply to work around some limitations in coverage
28+
measurement. As of now, there is only one patch: ``os._exit`` lets coverage
29+
save its data even when :func:`os._exit() <python:os._exit>` is used to
30+
abruptly end the process. This closes long-standing `issue 310`_ as well as
31+
its duplicates: `issue 312`_, `issue 1845`_, and `issue 1941`_.
32+
33+
- Two new exclusion patterns are part of the defaults: ``...`` is automatically
34+
excluded as a line and ``if TYPE_CHECKING:`` is excluded as a branch. Closes
2835
`issue 831`_.
2936

37+
.. _issue 310: https://github.com/nedbat/coveragepy/issues/310
38+
.. _issue 312: https://github.com/nedbat/coveragepy/issues/312
3039
.. _issue 831: https://github.com/nedbat/coveragepy/issues/831
40+
.. _issue 1845: https://github.com/nedbat/coveragepy/issues/1845
41+
.. _issue 1941: https://github.com/nedbat/coveragepy/issues/1941
3142

3243

3344
.. start-releases

coverage/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ def __init__(self) -> None:
207207
self.disable_warnings: list[str] = []
208208
self.dynamic_context: str | None = None
209209
self.parallel = False
210+
self.patch: list[str] = []
210211
self.plugins: list[str] = []
211212
self.relative_files = False
212213
self.run_include: list[str] = []
@@ -267,6 +268,7 @@ def __init__(self) -> None:
267268
"debug", "concurrency", "plugins",
268269
"report_omit", "report_include",
269270
"run_omit", "run_include",
271+
"patch",
270272
}
271273

272274
def from_args(self, **kwargs: TConfigValueIn) -> None:
@@ -390,6 +392,7 @@ def copy(self) -> CoverageConfig:
390392
("disable_warnings", "run:disable_warnings", "list"),
391393
("dynamic_context", "run:dynamic_context"),
392394
("parallel", "run:parallel", "boolean"),
395+
("patch", "run:patch", "list"),
393396
("plugins", "run:plugins", "list"),
394397
("relative_files", "run:relative_files", "boolean"),
395398
("run_include", "run:include", "list"),

coverage/control.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from coverage.misc import bool_or_none, join_regex
4343
from coverage.misc import DefaultValue, ensure_dir_for_file, isolate_module
4444
from coverage.multiproc import patch_multiprocessing
45+
from coverage.patch import apply_patches
4546
from coverage.plugin import FileReporter
4647
from coverage.plugin_support import Plugins, TCoverageInit
4748
from coverage.python import PythonFileReporter
@@ -677,6 +678,8 @@ def start(self) -> None:
677678
if self._auto_load:
678679
self.load()
679680

681+
apply_patches(self, self.config)
682+
680683
self._collector.start()
681684
self._started = True
682685
self._instances.append(self)

coverage/patch.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
2+
# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt
3+
4+
"""Invasive patches for coverage.py"""
5+
6+
from __future__ import annotations
7+
8+
import os
9+
10+
from typing import NoReturn, TYPE_CHECKING
11+
12+
if TYPE_CHECKING:
13+
from coverage import Coverage
14+
from coverage.config import CoverageConfig
15+
16+
def apply_patches(cov: Coverage, config: CoverageConfig) -> None:
17+
"""Apply invasive patches requested by `[run] patch=`."""
18+
19+
if "os._exit" in config.patch:
20+
_old_os_exit = os._exit
21+
def _coverage_os_exit_patch(status: int) -> NoReturn:
22+
try:
23+
cov.save()
24+
except: # pylint: disable=bare-except
25+
pass
26+
_old_os_exit(status)
27+
os._exit = _coverage_os_exit_patch

doc/config.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,29 @@ to the data file name to simplify collecting data from many processes. See
427427
:ref:`cmd_combine` for more information.
428428

429429

430+
.. _config_run_patch:
431+
432+
[run] patch
433+
...........
434+
435+
(multi-string) A list of patch names that coverage can apply to the runtime
436+
environment to improve coverage measurement. The available patches correspond
437+
to Python features that could interfere with coverage. The patch inserts
438+
coverage-specific code to collect the correct data.
439+
440+
These must be requested in the configuration instead of being applied
441+
automatically because they could be invasive and produce undesirable
442+
side-effects.
443+
444+
Currently there is only one available patch:
445+
446+
- ``os._exit``: The :func:`os._exit() <python:os._exit>` function exits the
447+
process immediately without calling cleanup handlers. This patch saves
448+
coverage data before exiting.
449+
450+
.. versionadded:: 7.10
451+
452+
430453
.. _config_run_plugins:
431454

432455
[run] plugins

doc/subprocess.rst

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@ If your processes are ending with SIGTERM, you must enable the
2525
:ref:`config_run_sigterm` setting to configure coverage to catch SIGTERM
2626
signals and write its data.
2727

28-
Other ways of ending a process, like SIGKILL or :func:`os._exit
28+
Other ways of ending a process, like SIGKILL or :func:`os._exit()
2929
<python:os._exit>`, will prevent coverage.py from writing its data file,
30-
leaving you with incomplete or non-existent coverage data.
30+
leaving you with incomplete or non-existent coverage data. SIGKILL can't be
31+
intercepted, but you can fix :func:`!os._exit` with the :ref:`[run] patch
32+
option <config_run_patch>`.
3133

3234
.. note::
3335

tests/test_process.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,43 @@ def test_fork(self) -> None:
421421
reported_pids = {line.split(".")[0] for line in debug_text.splitlines()}
422422
assert len(reported_pids) == 2
423423

424+
@pytest.mark.skipif(not hasattr(os, "fork"), reason="Can't test os.fork, it doesn't exist.")
425+
@pytest.mark.parametrize("patch", [False, True])
426+
def test_os_exit(self, patch: bool) -> None:
427+
self.make_file("forky.py", """\
428+
import os
429+
import tempfile
430+
import time
431+
432+
complete_file = tempfile.mkstemp()[1]
433+
pid = os.fork()
434+
if pid:
435+
while pid: # 3.9 wouldn't count "while True": change this. PYVERSION
436+
with open(complete_file, encoding="ascii") as f:
437+
data = f.read()
438+
if "Complete" in data:
439+
break
440+
time.sleep(.02)
441+
os.remove(complete_file)
442+
else:
443+
time.sleep(.1)
444+
with open(complete_file, mode="w", encoding="ascii") as f:
445+
f.write("Complete")
446+
os._exit(0)
447+
""")
448+
total_lines = 17
449+
if patch:
450+
self.make_file(".coveragerc", "[run]\npatch = os._exit\n")
451+
self.run_command("coverage run -p forky.py")
452+
self.run_command("coverage combine")
453+
data = coverage.CoverageData()
454+
data.read()
455+
seen = line_counts(data)["forky.py"]
456+
if patch:
457+
assert seen == total_lines
458+
else:
459+
assert seen < total_lines
460+
424461
def test_warnings_during_reporting(self) -> None:
425462
# While fixing issue #224, the warnings were being printed far too
426463
# often. Make sure they're not any more.

0 commit comments

Comments
 (0)