Skip to content

Commit 8997bdc

Browse files
committed
Add FFPuppet.dump_coverage()
1 parent 8476cdc commit 8997bdc

File tree

4 files changed

+215
-2
lines changed

4 files changed

+215
-2
lines changed

src/ffpuppet/core.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,22 @@ def cpu_usage(self) -> Generator[Tuple[int, float], None, None]:
590590
if self._proc_tree is not None:
591591
yield from self._proc_tree.cpu_usage()
592592

593+
def dump_coverage(self, timeout: int = 15) -> None:
594+
"""Signal browser to write coverage data to disk.
595+
596+
Args:
597+
timeout: Number of seconds to wait for data to be written to disk.
598+
599+
Returns:
600+
None
601+
"""
602+
if system() != "Linux": # pragma: no cover
603+
raise NotImplementedError("dump_coverage() is not available")
604+
if self._proc_tree is not None:
605+
if not self._proc_tree.dump_coverage(timeout=timeout):
606+
LOG.warning("Timeout writing coverage data")
607+
self.close()
608+
593609
def get_pid(self) -> Optional[int]:
594610
"""Get the browser parent process ID.
595611

src/ffpuppet/process_tree.py

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,60 @@
44
"""ffpuppet process tree module"""
55

66
from logging import getLogger
7+
from os import getenv
8+
from pathlib import Path
79
from platform import system
810
from subprocess import Popen
9-
from time import sleep
11+
from time import perf_counter, sleep
1012
from typing import Generator, List, Optional, Tuple
1113

14+
try:
15+
from signal import SIGUSR1, Signals
16+
17+
COVERAGE_SIGNAL: Optional[Signals] = SIGUSR1
18+
except ImportError:
19+
COVERAGE_SIGNAL = None
20+
1221
from psutil import AccessDenied, NoSuchProcess, Process, TimeoutExpired, wait_procs
1322

1423
from .exceptions import TerminateError
1524

1625
LOG = getLogger(__name__)
1726

1827

28+
def _last_modified(scan_dir: Path) -> Optional[float]:
29+
"""Scan directory recursively and find the latest modified date of all .gcda files.
30+
31+
Args:
32+
scan_dir: Directory to scan.
33+
34+
Returns:
35+
Last modified date or None if no files are found.
36+
"""
37+
try:
38+
return max(x.stat().st_mtime for x in scan_dir.glob("**/*.gcda"))
39+
except ValueError:
40+
return None
41+
42+
43+
def _writing_coverage(procs: List[Process]) -> bool:
44+
"""Check if any processes have open .gcda files.
45+
46+
Args:
47+
procs: List of processes to check.
48+
49+
Returns:
50+
True if processes with open .gcda files are found.
51+
"""
52+
for proc in procs:
53+
try:
54+
if any(x for x in proc.open_files() if x.path.endswith(".gcda")):
55+
return True
56+
except (AccessDenied, NoSuchProcess): # pragma: no cover
57+
pass
58+
return False
59+
60+
1961
class ProcessTree:
2062
"""Manage the Firefox process tree. The process tree layout depends on the platform.
2163
Windows:
@@ -64,6 +106,69 @@ def cpu_usage(self) -> Generator[Tuple[int, float], None, None]:
64106
except (AccessDenied, NoSuchProcess): # pragma: no cover
65107
continue
66108

109+
def dump_coverage(self, timeout: int = 15, idle_wait: int = 2) -> bool:
110+
"""Signal processes to write coverage data to disk. Running coverage builds in
111+
parallel that are writing to the same location on disk is not recommended.
112+
NOTE: Coverage data is also written when launching and closing the browser.
113+
114+
Args:
115+
timeout: Number of seconds to wait for data to be written to disk.
116+
idle_wait: Number of seconds to wait to determine if update is complete.
117+
118+
Returns:
119+
True if coverage is written to disk otherwise False.
120+
"""
121+
assert COVERAGE_SIGNAL is not None
122+
assert getenv("GCOV_PREFIX_STRIP"), "GCOV_PREFIX_STRIP not set"
123+
assert getenv("GCOV_PREFIX"), "GCOV_PREFIX not set"
124+
# coverage output can take a few seconds to start and complete
125+
assert timeout > 5
126+
cov_path = Path(getenv("GCOV_PREFIX")) # type: ignore[arg-type]
127+
last_mdate = _last_modified(cov_path) or 0
128+
signaled = 0
129+
# send COVERAGE_SIGNAL (SIGUSR1) to browser processes
130+
for proc in self.processes():
131+
try:
132+
proc.send_signal(COVERAGE_SIGNAL)
133+
signaled += 1
134+
except (AccessDenied, NoSuchProcess): # pragma: no cover
135+
pass
136+
# no processes signaled
137+
if signaled == 0:
138+
LOG.warning("Coverage signal not sent, no browser processes found")
139+
return False
140+
# wait for processes to write .gcda files (typically takes ~2 seconds)
141+
start_time = perf_counter()
142+
last_change = None
143+
success = False
144+
while self.is_running():
145+
# collect latest last modified dates
146+
mdate = _last_modified(cov_path) or 0
147+
# check if gcda files have been updated
148+
now = perf_counter()
149+
elapsed = now - start_time
150+
if mdate > last_mdate:
151+
last_change = now
152+
last_mdate = mdate
153+
# check if gcda write is complete (wait)
154+
if (
155+
last_change is not None
156+
and now - last_change > idle_wait
157+
and not _writing_coverage(self.processes())
158+
):
159+
LOG.debug("coverage (gcda) dump took %0.2fs", elapsed)
160+
success = True
161+
break
162+
# check if max duration has been exceeded
163+
if elapsed >= timeout:
164+
if last_change is None:
165+
LOG.warning("Coverage files not modified after %0.2fs", elapsed)
166+
else:
167+
LOG.warning("Coverage file open after %0.2fs", elapsed)
168+
break
169+
sleep(0.25)
170+
return success
171+
67172
def is_running(self) -> bool:
68173
"""Check if parent process is running.
69174

src/ffpuppet/test_ffpuppet.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,3 +960,19 @@ def test_ffpuppet_32(mocker):
960960
assert config_job_object.mock_calls[0] == mocker.call(123, 456)
961961
assert resume_suspended.call_count == 1
962962
assert resume_suspended.mock_calls[0] == mocker.call(789)
963+
964+
965+
def test_ffpuppet_33(mocker):
966+
"""test FFPuppet.dump_coverage()"""
967+
with FFPuppet() as ffp:
968+
ffp._proc_tree = mocker.Mock(spec_set=ProcessTree)
969+
ffp._proc_tree.dump_coverage.side_effect = (True, False)
970+
if system() == "Linux":
971+
# success
972+
ffp.dump_coverage()
973+
# hang
974+
ffp.dump_coverage()
975+
else:
976+
with raises(NotImplementedError):
977+
ffp.dump_coverage()
978+
ffp._proc_tree = None

src/ffpuppet/test_process_tree.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
# You can obtain one at http://mozilla.org/MPL/2.0/.
44
"""process_tree.py tests"""
55

6+
from collections import namedtuple
7+
from itertools import chain, count, repeat
68
from pathlib import Path
79
from subprocess import Popen
810
from time import sleep
@@ -11,7 +13,7 @@
1113
from pytest import mark, raises
1214

1315
from .exceptions import TerminateError
14-
from .process_tree import ProcessTree
16+
from .process_tree import ProcessTree, _last_modified, _writing_coverage
1517

1618
TREE = Path(__file__).parent / "resources" / "tree.py"
1719

@@ -169,3 +171,77 @@ def test_process_tree_04(mocker):
169171
assert stats
170172
assert stats[0][0] == 1234
171173
assert stats[0][1] == 2.3
174+
175+
176+
@mark.parametrize(
177+
"procs, last_mod, writing, success",
178+
[
179+
# no processes
180+
(False, repeat(0), False, False),
181+
# data written successfully
182+
(True, chain([0], repeat(2)), False, True),
183+
# data not updated
184+
(True, repeat(0), False, False),
185+
# data write timeout
186+
(True, chain([0], repeat(2)), True, False),
187+
],
188+
)
189+
def test_process_tree_05(mocker, procs, last_mod, writing, success):
190+
"""test ProcessTree.dump_coverage()"""
191+
mocker.patch("ffpuppet.process_tree.COVERAGE_SIGNAL", return_value="foo")
192+
mocker.patch("ffpuppet.process_tree.getenv", return_value="foo")
193+
mocker.patch("ffpuppet.process_tree.perf_counter", side_effect=count(step=0.25))
194+
mocker.patch("ffpuppet.process_tree.sleep", autospec=True)
195+
mocker.patch("ffpuppet.process_tree._last_modified", side_effect=last_mod)
196+
mocker.patch("ffpuppet.process_tree._writing_coverage", return_value=writing)
197+
198+
# pylint: disable=missing-class-docstring,super-init-not-called
199+
class CovProcessTree(ProcessTree):
200+
def __init__(self):
201+
pass
202+
203+
def is_running(self) -> bool:
204+
return True
205+
206+
def processes(self, recursive=False):
207+
return [] if not procs else [mocker.Mock(spec_set=Process)]
208+
209+
tree = CovProcessTree()
210+
assert tree.dump_coverage() == success
211+
212+
213+
def test_last_modified_01(tmp_path):
214+
"""test ProcessTree._last_modified()"""
215+
# scan missing path
216+
assert _last_modified(tmp_path / "missing") is None
217+
# scan empty path
218+
assert _last_modified(tmp_path) is None
219+
# scan path without gcda files
220+
(tmp_path / "somefile.txt").touch()
221+
assert _last_modified(tmp_path) is None
222+
# scan nested path with gcda files
223+
(tmp_path / "a").mkdir()
224+
(tmp_path / "a" / "file.gcda").touch()
225+
assert _last_modified(tmp_path) > 0
226+
227+
228+
def test_writing_coverage_01(mocker):
229+
"""test ProcessTree._writing_coverage()"""
230+
openfile = namedtuple("openfile", ["path", "fd"])
231+
# empty list
232+
assert not _writing_coverage([])
233+
# no open files
234+
proc = mocker.Mock(spec_set=Process, pid=1337)
235+
proc.open_files.return_value = ()
236+
assert not _writing_coverage([proc])
237+
assert proc.open_files.call_count == 1
238+
# open test
239+
proc.reset_mock()
240+
proc.open_files.return_value = (openfile("file.txt", None),)
241+
assert not _writing_coverage([proc])
242+
assert proc.open_files.call_count == 1
243+
# open gcda
244+
proc.reset_mock()
245+
proc.open_files.return_value = (openfile("file.gcda", None),)
246+
assert _writing_coverage([proc])
247+
assert proc.open_files.call_count == 1

0 commit comments

Comments
 (0)