From c2bb427b9cb0754e09a3483ccbbb12303e4ca1f2 Mon Sep 17 00:00:00 2001 From: Tyson Smith Date: Tue, 11 Jun 2024 16:46:12 -0700 Subject: [PATCH] Use FFPuppet.dump_coverage() --- grizzly/args.py | 23 ++++-- grizzly/target/puppet_target.py | 107 +------------------------ grizzly/target/test_puppet_target.py | 112 +-------------------------- 3 files changed, 25 insertions(+), 217 deletions(-) diff --git a/grizzly/args.py b/grizzly/args.py index 02dd9a2d..114483c8 100644 --- a/grizzly/args.py +++ b/grizzly/args.py @@ -411,9 +411,15 @@ def __init__(self) -> None: " target is relaunched.", ) - self.launcher_grp.add_argument( - "--coverage", action="store_true", help="Enable coverage collection." - ) + if system().startswith("Linux"): + self.launcher_grp.add_argument( + "--coverage", + action="store_true", + help="Dump coverage data to disk (requires a supported browser build).", + ) + else: + self.parser.set_defaults(coverage=False) + self.launcher_grp.add_argument( "--runtime", type=int, @@ -447,6 +453,12 @@ def sanity_check(self, args: Namespace) -> None: if args.collect < 1: self.parser.error("--collect must be greater than 0") + if args.coverage and args.platform == "ffpuppet": + if not getenv("GCOV_PREFIX_STRIP"): + self.parser.error("GCOV_PREFIX_STRIP must be set to use --coverage") + if not getenv("GCOV_PREFIX"): + self.parser.error("GCOV_PREFIX must be set to use --coverage") + if args.input and not args.input.exists(): self.parser.error(f"'{args.input}' does not exist") @@ -460,6 +472,5 @@ def sanity_check(self, args: Namespace) -> None: self.parser.error("--runtime must be >= 0") if args.smoke_test: - if args.limit == 0: - # set iteration limit for smoke-test - args.limit = 10 + # set iteration limit for smoke-test + args.limit = args.limit or 10 diff --git a/grizzly/target/puppet_target.py b/grizzly/target/puppet_target.py index 87bf0809..fa1af1c5 100644 --- a/grizzly/target/puppet_target.py +++ b/grizzly/target/puppet_target.py @@ -1,28 +1,18 @@ # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. -from itertools import chain from logging import getLogger from os import kill from pathlib import Path from platform import system -from signal import SIGABRT, Signals +from signal import SIGABRT from tempfile import TemporaryDirectory, mkdtemp -from time import sleep, time from typing import Any, Dict, Optional, Set, cast -try: - from signal import SIGUSR1 # pylint: disable=ungrouped-imports - - COVERAGE_SIG: Optional[Signals] = SIGUSR1 -except ImportError: - COVERAGE_SIG = None - from ffpuppet import BrowserTimeoutError, Debugger, FFPuppet, LaunchError, Reason from ffpuppet.helpers import certutil_available, certutil_find from ffpuppet.sanitizer_util import SanitizerOptions from prefpicker import PrefPicker -from psutil import AccessDenied, NoSuchProcess, Process, process_iter, wait_procs from sapphire import CertificateBundle @@ -249,98 +239,9 @@ def handle_hang( def https(self) -> bool: return self._https - def dump_coverage(self, timeout: int = 5) -> None: - if system() != "Linux": - LOG.debug("dump_coverage() only supported on Linux") - return - - assert COVERAGE_SIG is not None - pid = self._puppet.get_pid() - if pid is None or not self._puppet.is_healthy(): - LOG.debug("Skipping coverage dump (target is not in a good state)") - return - # If at this point, the browser is in a good state, i.e. no crashes - # or hangs, so signal the browser to dump coverage. - running_procs = 0 - signaled_pids: Set[int] = set() - try: - # send COVERAGE_SIG (SIGUSR1) to browser processes - # TODO: this should use FFPuppet.processes() - parent_proc = Process(pid) - for proc in chain([parent_proc], parent_proc.children(recursive=True)): - # avoid sending signal to non-browser processes - if Path(proc.exe()).name.startswith("firefox"): - LOG.debug( - "Sending signal to %d (%s)", - proc.pid, - "parent" if proc.pid == pid else "child", - ) - try: - kill(proc.pid, COVERAGE_SIG) - signaled_pids.add(proc.pid) - except OSError: - LOG.warning("Failed to send signal to pid %d", proc.pid) - if proc.is_running(): - running_procs += 1 - except (AccessDenied, NoSuchProcess): # pragma: no cover - pass - if not signaled_pids: - LOG.warning( - "Signal not sent, no browser processes found (%d process(es) running)", - running_procs, - ) - return - start_time = time() - gcda_found = False - delay = 0.1 - # wait for processes to write .gcda files (typically takes <1 second) - while True: - for proc in process_iter(attrs=["pid", "open_files"]): - # scan signaled processes for open .gcda files - if ( - proc.info["pid"] in signaled_pids - and proc.info["open_files"] - and any(x.path.endswith(".gcda") for x in proc.info["open_files"]) - ): - gcda_found = True - # TODO: collect all process with open files - # collect pid of process with open .gcda file - gcda_open = proc.info["pid"] - break - else: - gcda_open = None - elapsed = time() - start_time - if gcda_found: - if gcda_open is None: - # success - LOG.debug("gcda dump took %0.2fs", elapsed) - break - if elapsed >= timeout: - # timeout waiting for .gcda file to be written - LOG.warning( - "gcda file open by pid %d after %0.2fs", gcda_open, elapsed - ) - try: - kill(gcda_open, SIGABRT) - # wait for logs - wait_procs([Process(gcda_open)], timeout=5) - except (AccessDenied, NoSuchProcess, OSError): # pragma: no cover - pass - self.close() - break - if delay < 1.0: - # increase delay to a maximum of 1 second - # it is increased when waiting for the .gcda files to be written - # this decreases the number of calls to process_iter() - delay = min(1.0, delay + 0.1) - elif elapsed >= 10: - # assume we missed the process writing .gcda files - LOG.warning("No gcda files seen after %0.2fs", elapsed) - break - if not self._puppet.is_healthy(): - LOG.warning("Browser failure during dump_coverage()") - break - sleep(delay) + def dump_coverage(self, timeout: int = 15) -> None: + if self._puppet.is_healthy(): + self._puppet.dump_coverage(timeout=timeout) def launch(self, location: str) -> None: # setup environment diff --git a/grizzly/target/test_puppet_target.py b/grizzly/target/test_puppet_target.py index 330d8e23..e0c3d208 100644 --- a/grizzly/target/test_puppet_target.py +++ b/grizzly/target/test_puppet_target.py @@ -2,7 +2,6 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. # pylint: disable=protected-access -from itertools import count from platform import system from ffpuppet import BrowserTerminatedError, BrowserTimeoutError, Debugger, Reason @@ -149,116 +148,13 @@ def test_puppet_target_04(mocker, tmp_path, healthy, usage, os_name, killed, deb @mark.skipif(system() != "Linux", reason="Linux only") def test_puppet_target_05(mocker, tmp_path): - """test PuppetTarget.dump_coverage() - full test""" - mocker.patch("grizzly.target.puppet_target.wait_procs", autospec=True) + """test PuppetTarget.dump_coverage()""" fake_ffp = mocker.patch("grizzly.target.puppet_target.FFPuppet", autospec=True) - child_proc = mocker.Mock(pid=101) - child_proc.exe.return_value = "firefox-bin" - fake_proc = mocker.patch("grizzly.target.puppet_target.Process", autospec=True) - fake_proc.return_value.pid = 100 - fake_proc.return_value.children.return_value = (child_proc,) - fake_proc.return_value.exe.return_value = "firefox-bin" - fake_proc_iter = mocker.patch( - "grizzly.target.puppet_target.process_iter", autospec=True - ) - mocker.patch("grizzly.target.puppet_target.sleep", autospec=True) - fake_time = mocker.patch("grizzly.target.puppet_target.time", autospec=True) fake_file = tmp_path / "fake" fake_file.touch() - target = PuppetTarget(fake_file, 300, 25, 5000) - fake_kill = mocker.patch("grizzly.target.puppet_target.kill", autospec=True) - # not running - fake_ffp.return_value.get_pid.return_value = None - target.dump_coverage() - assert not fake_kill.call_count - assert fake_ffp.return_value.get_pid.call_count == 1 - assert fake_proc_iter.call_count == 0 - # gcda not found - fake_ffp.return_value.is_healthy.return_value = True - fake_ffp.return_value.get_pid.return_value = 100 - fake_time.side_effect = (0, 1, 10) - target.dump_coverage() - assert fake_kill.call_count == 2 - assert fake_proc_iter.call_count == 2 - assert fake_ffp.return_value.is_healthy.call_count == 2 - fake_ffp.reset_mock() - fake_kill.reset_mock() - fake_proc_iter.reset_mock() - # browser crashes - fake_ffp.return_value.is_healthy.side_effect = (True, False) - fake_time.side_effect = None - fake_time.return_value = 1.0 - target.dump_coverage() - assert fake_kill.call_count == 2 - assert fake_proc_iter.call_count == 1 - assert fake_ffp.return_value.is_healthy.call_count == 2 - fake_ffp.reset_mock() - fake_kill.reset_mock() - fake_proc_iter.reset_mock() - # timeout while waiting for files - fake_ffp.return_value.is_healthy.return_value = True - fake_ffp.return_value.is_healthy.side_effect = None - fake_ffp.return_value.get_pid.return_value = 100 - fake_proc_iter.return_value = ( - mocker.Mock(info={"pid": 101, "open_files": (mocker.Mock(path="a.gcda"),)}), - ) - fake_time.side_effect = (0, 1, 20) - target.dump_coverage(timeout=15) - assert fake_kill.call_count == 3 - assert fake_proc_iter.call_count == 2 - assert fake_ffp.return_value.is_healthy.call_count == 2 - assert fake_ffp.return_value.close.call_count == 1 - fake_ffp.reset_mock() - fake_kill.reset_mock() - fake_proc_iter.reset_mock() - # wait for files (success) - fake_ffp.return_value.get_pid.return_value = 100 - fake_time.side_effect = None - fake_time.return_value = 1.0 - fake_proc_iter.side_effect = ( - # 1st call - ( - mocker.Mock( - info={ - "pid": 100, - "open_files": ( - mocker.Mock(path="a.bin"), - mocker.Mock(path="/a/s/d"), - ), - } - ), - mocker.Mock(info={"pid": 101, "open_files": None}), - mocker.Mock(info={"pid": 999, "open_files": None}), - ), - # 2nd call - (mocker.Mock(info={"pid": 100, "open_files": (mocker.Mock(path="a.gcda"),)}),), - # 3rd call - ( - mocker.Mock(info={"pid": 100, "open_files": (mocker.Mock(path="a.bin"),)}), - mocker.Mock( - info={"pid": 999, "open_files": (mocker.Mock(path="ignore.gcda"),)} - ), - ), - ) - target.dump_coverage() - assert fake_ffp.return_value.close.call_count == 0 - assert fake_proc_iter.call_count == 3 - assert fake_kill.call_count == 2 - fake_ffp.reset_mock() - fake_kill.reset_mock() - fake_proc_iter.reset_mock() - # kill calls raise OSError - fake_kill.side_effect = OSError - fake_ffp.return_value.is_healthy.return_value = True - fake_ffp.return_value.get_pid.return_value = 100 - fake_proc_iter.side_effect = None - fake_time.side_effect = count() - target.dump_coverage() - assert fake_kill.call_count == 2 - fake_ffp.reset_mock() - fake_kill.reset_mock() - fake_proc_iter.reset_mock() - target.cleanup() + with PuppetTarget(fake_file, 300, 25, 5000) as target: + target.dump_coverage() + assert fake_ffp.return_value.dump_coverage.call_count == 1 def test_puppet_target_06(mocker, tmp_path):