Skip to content

Commit

Permalink
Add FFPuppet.dump_coverage()
Browse files Browse the repository at this point in the history
  • Loading branch information
tysmith committed Jun 10, 2024
1 parent 8476cdc commit 36530f8
Show file tree
Hide file tree
Showing 5 changed files with 169 additions and 1 deletion.
16 changes: 16 additions & 0 deletions src/ffpuppet/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,22 @@ def cpu_usage(self) -> Generator[Tuple[int, float], None, None]:
if self._proc_tree is not None:
yield from self._proc_tree.cpu_usage()

def dump_coverage(self, timeout: int = 15) -> None:
"""Signal browser to write coverage data to disk.
Args:
timeout: Number of seconds to wait for data to be written to disk.
Returns:
None
"""
if system() != "Linux": # pragma: no cover
raise NotImplementedError("dump_coverage() is not available")
if self._proc_tree is not None and self.is_healthy():
if not self._proc_tree.dump_coverage(timeout=timeout):
LOG.warning("Timeout writing coverage data")
self.close()

def get_pid(self) -> Optional[int]:
"""Get the browser parent process ID.
Expand Down
71 changes: 70 additions & 1 deletion src/ffpuppet/process_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,19 @@
"""ffpuppet process tree module"""

from logging import getLogger
from os import getenv
from platform import system
from subprocess import Popen
from time import sleep
from time import perf_counter, sleep
from typing import Generator, List, Optional, Tuple

try:
from signal import SIGUSR1, Signals

COVERAGE_SIGNAL: Optional[Signals] = SIGUSR1
except ImportError:
COVERAGE_SIGNAL = None

from psutil import AccessDenied, NoSuchProcess, Process, TimeoutExpired, wait_procs

from .exceptions import TerminateError
Expand Down Expand Up @@ -64,6 +72,67 @@ def cpu_usage(self) -> Generator[Tuple[int, float], None, None]:
except (AccessDenied, NoSuchProcess): # pragma: no cover
continue

def dump_coverage(self, timeout: int = 15) -> bool:
"""Signal processes to write coverage data to disk.
Args:
timeout: Number of seconds to wait for data to be written to disk.
Returns:
True unless timeout is exceeded.
"""
assert COVERAGE_SIGNAL is not None
assert getenv("GCOV_PREFIX_STRIP"), "GCOV_PREFIX_STRIP not set"
assert getenv("GCOV_PREFIX"), "GCOV_PREFIX not set"
# coverage output takes a few seconds to start and complete
assert timeout > 5
signaled = 0
# send COVERAGE_SIGNAL (SIGUSR1) to browser processes
for proc in self.processes():
try:
proc.send_signal(COVERAGE_SIGNAL)
signaled += 1
except (AccessDenied, NoSuchProcess): # pragma: no cover
pass
# no processes signaled
if signaled == 0:
LOG.warning("Signal not sent, no browser processes found")
return True
# wait for processes to write .gcda files (typically takes <1 second)
start_time = perf_counter()
last_seen = None
success = True
while True:
is_running = False
# look for open gcda files
for proc in self.processes():
try:
if not is_running and proc.is_running():
is_running = True
if any(x for x in proc.open_files() if x.path.endswith(".gcda")):
last_seen = perf_counter()
break
except (AccessDenied, NoSuchProcess): # pragma: no cover
pass
if not is_running:
LOG.debug("processes exited while waiting for coverage")
break
# check if max duration has been exceeded
elapsed = perf_counter() - start_time
if elapsed >= timeout:
if last_seen is None:
LOG.warning("No gcda files seen after %0.2fs", elapsed)
else:
LOG.warning("gcda file open after %0.2fs", elapsed)
success = False
break
# check if gcda file has been written
if last_seen is not None and perf_counter() - last_seen > 1:
LOG.debug("gcda dump took %0.2fs", elapsed)
break
sleep(0.1)
return success

def is_running(self) -> bool:
"""Check if parent process is running.
Expand Down
15 changes: 15 additions & 0 deletions src/ffpuppet/resources/testff.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,31 @@

import os
import platform
import signal
import sys
import time
from pathlib import Path
from typing import Any
from urllib.error import URLError
from urllib.request import urlopen

EXIT_DELAY = 45


def _write_coverage(_signum: int, _frame: Any) -> None:
working_dir = os.getenv("GCOV_PREFIX")
assert working_dir
(Path(working_dir) / "file.gcda").write_text("foo")
# for the sake of brevity exit so we don't need to wait for timeout
sys.exit(0)


def main() -> int: # pylint: disable=missing-docstring
os_name = platform.system()

if os.getenv("GCOV_PREFIX") and os_name == "Linux":
signal.signal(signal.SIGUSR1, _write_coverage)

profile = url = None
while len(sys.argv) > 1:
arg = sys.argv.pop(1)
Expand Down
14 changes: 14 additions & 0 deletions src/ffpuppet/test_ffpuppet.py
Original file line number Diff line number Diff line change
Expand Up @@ -960,3 +960,17 @@ def test_ffpuppet_32(mocker):
assert config_job_object.mock_calls[0] == mocker.call(123, 456)
assert resume_suspended.call_count == 1
assert resume_suspended.mock_calls[0] == mocker.call(789)


@mark.skipif(system() != "Linux", reason="Only supported on Linux")
def test_ffpuppet_33(mocker, tmp_path):
"""test FFPuppet.dump_coverage()"""
env_mod = {"GCOV_PREFIX_STRIP": "0", "GCOV_PREFIX": str(tmp_path)}
mocker.patch.dict(os.environ, env_mod)
with FFPuppet() as ffp:
with HTTPTestServer() as srv:
ffp.launch(TESTFF_BIN, location=srv.get_addr(), env_mod=env_mod)
ffp.dump_coverage()
ffp.close()
assert ffp.reason == Reason.EXITED
assert any(x for x in tmp_path.iterdir() if x.suffix == ".gcda")
54 changes: 54 additions & 0 deletions src/ffpuppet/test_process_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
# You can obtain one at http://mozilla.org/MPL/2.0/.
"""process_tree.py tests"""

from collections import namedtuple
from itertools import count
from pathlib import Path
from platform import system
from subprocess import Popen
from time import sleep

Expand Down Expand Up @@ -169,3 +172,54 @@ def test_process_tree_04(mocker):
assert stats
assert stats[0][0] == 1234
assert stats[0][1] == 2.3


@mark.skipif(system() != "Linux", reason="Only supported on Linux")
@mark.parametrize(
"mode, expected",
[
("no gcda", True),
("no processes", True),
("process exits", True),
("success", True),
("timeout", False),
],
)
def test_process_tree_05(mocker, mode, expected):
"""test ProcessTree.dump_coverage()"""
mocker.patch("ffpuppet.process_tree.getenv", autospec=True)
mocker.patch("ffpuppet.process_tree.sleep", autospec=True)
mocker.patch("ffpuppet.process_tree.perf_counter", side_effect=count(step=0.3))

openfile = namedtuple("openfile", ["path", "fd"])

proc = mocker.Mock(spec_set=Process, pid=1337)
proc.is_running.return_value = mode != "process exits"
if mode == "success":
proc.open_files.side_effect = (
(),
(openfile("file.gcda", None),),
(openfile("file.gcda", None),),
(),
)
elif mode in {"no gcda", "no processes", "process exits"}:
proc.open_files.return_value = []
elif mode == "timeout":
proc.open_files.return_value = [openfile("file.gcda", None)]

# # pylint: disable=missing-class-docstring,super-init-not-called
class CovProcessTree(ProcessTree):
def __init__(self):
pass

def processes(self, recursive=False):
return [] if mode == "no processes" else [proc]

tree = CovProcessTree()
assert tree.dump_coverage() == expected
if mode == "no processes":
assert proc.open_files.call_count == 0
elif mode == "process exits":
assert proc.open_files.call_count == 1
else:
assert proc.open_files.call_count > 0

0 comments on commit 36530f8

Please sign in to comment.