Skip to content

Commit

Permalink
Use FFPuppet.dump_coverage()
Browse files Browse the repository at this point in the history
  • Loading branch information
tysmith committed Jun 19, 2024
1 parent 637e5f4 commit 12ab4ae
Show file tree
Hide file tree
Showing 5 changed files with 36 additions and 223 deletions.
23 changes: 17 additions & 6 deletions grizzly/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")

Check warning on line 460 in grizzly/args.py

View check run for this annotation

Codecov / codecov/patch

grizzly/args.py#L457-L460

Added lines #L457 - L460 were not covered by tests

if args.input and not args.input.exists():
self.parser.error(f"'{args.input}' does not exist")

Expand All @@ -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
107 changes: 4 additions & 103 deletions grizzly/target/puppet_target.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down
112 changes: 4 additions & 108 deletions grizzly/target/test_puppet_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
9 changes: 7 additions & 2 deletions grizzly/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
"""test Grizzly main"""
from pathlib import Path
from platform import system

from pytest import mark
from pytest import mark, skip

from .args import GrizzlyArgs
from .common.utils import Exit
Expand Down Expand Up @@ -41,6 +42,9 @@
)
def test_main_01(mocker, session_setup, adpt_relaunch, extra_args):
"""test main()"""
cov_support = system() == "Linux"
if "--coverage" in extra_args and not cov_support:
skip(f"--coverage not available on {system()}")
mocker.patch(
"grizzly.args.scan_plugins",
autospec=True,
Expand All @@ -55,7 +59,8 @@ def test_main_01(mocker, session_setup, adpt_relaunch, extra_args):
session_obj.status.results.total = 1 if args.smoke_test else 0

assert main(args) == (Exit.ERROR if args.smoke_test else Exit.SUCCESS)
assert session_cls.mock_calls[0][-1]["coverage"] == args.coverage
if cov_support:
assert session_cls.mock_calls[0][-1]["coverage"] == args.coverage
if adpt_relaunch:
assert session_cls.mock_calls[0][-1]["relaunch"] == adpt_relaunch
else:
Expand Down
8 changes: 4 additions & 4 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@ install_requires =
cryptography
cssbeautifier
fasteners
ffpuppet >= 0.11.1
ffpuppet >= 0.13.1
FuzzManager
jsbeautifier
lithium-reducer >= 2.0.0
prefpicker >= 1.1.0
psutil >= 5.9.0
lithium-reducer >= 2.0.1
prefpicker >= 1.23.0
psutil >= 5.9.8
packages =
grizzly
grizzly.adapter
Expand Down

0 comments on commit 12ab4ae

Please sign in to comment.