diff --git a/.github/workflows/check_bazel_tests.yml b/.github/workflows/check_bazel_tests.yml index f33c735..04a92f0 100644 --- a/.github/workflows/check_bazel_tests.yml +++ b/.github/workflows/check_bazel_tests.yml @@ -147,7 +147,7 @@ jobs: OPENSSL_LIBRARY_PREFIX: "/usr/lib/x86_64-linux-gnu" OPENSSL_INCLUDE_PREFIX: "/usr/include/openssl" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v6 if: failure() with: name: "${{ matrix.name }}" diff --git a/.github/workflows/check_mull.yml b/.github/workflows/check_mull.yml new file mode 100644 index 0000000..bfe4a09 --- /dev/null +++ b/.github/workflows/check_mull.yml @@ -0,0 +1,74 @@ +name: Check Mull Mutation Tests + +on: + pull_request: + workflow_dispatch: + +env: + USE_BAZEL_VERSION: 8.3.1 + +jobs: + discover_mull_targets: + runs-on: ubuntu-24.04 + outputs: + mull_matrix: ${{ steps.discover.outputs.mull_matrix }} + steps: + - uses: actions/checkout@v6 + + - uses: bazel-contrib/setup-bazel@8cb04a772ab4c1eb984e9c1b493a182e96c5e425 # 0.19.0 + with: + bazelisk-cache: true + + - name: Setup Python & Poetry Environment + uses: exasol/python-toolbox/.github/actions/python-environment@v9 + with: + python-version: "3.10" + poetry-version: "2.3.0" + + - name: Install Poetry dependencies + run: poetry install --with dev + + - name: Discover Mull targets + id: discover + run: poetry run -- nox --sessions=mull-targets -- --github-output-var mull_matrix + + mull: + needs: discover_mull_targets + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.discover_mull_targets.outputs.mull_matrix) }} + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + + - uses: bazel-contrib/setup-bazel@8cb04a772ab4c1eb984e9c1b493a182e96c5e425 # 0.19.0 + with: + bazelisk-cache: true + + - name: Install LLVM and Mull + run: | + sudo apt-get update + sudo apt-get install -y clang-20 + curl -1sLf 'https://dl.cloudsmith.io/public/mull-project/mull-stable/setup.deb.sh' | sudo -E bash + sudo apt-get install -y mull-20=0.34.1 + mull-runner-20 --version + + - name: Setup Python & Poetry Environment + uses: exasol/python-toolbox/.github/actions/python-environment@v9 + with: + python-version: "3.10" + poetry-version: "2.3.0" + + - name: Install Poetry dependencies + run: poetry install --with dev + + - name: Run Mull mutation tests + run: poetry run -- nox --sessions=mull -- --target "${{ matrix.target }}" + + - name: Upload Mull reports + if: always() + uses: actions/upload-artifact@v6 + with: + name: mull-reports-${{ matrix.target }} + path: .build_output/mull/ + if-no-files-found: warn diff --git a/doc/changes/unreleased.md b/doc/changes/unreleased.md index 5890e45..4319db4 100644 --- a/doc/changes/unreleased.md +++ b/doc/changes/unreleased.md @@ -19,6 +19,11 @@ n/a ## Internal +* #60: Added Mull mutation testing workflow and report-viewing documentation + for v2; targets without generated mutants now produce warnings instead of + failing the workflow, and separated Linux EventFd code and factory-based + Linux queue construction from the generic waitable-queue mutation target; + updated artifact uploads to the Node.js 24-compatible action version * #64: Added GoogleTest, GoogleMock, and Google Benchmark support for v2 tests * #57: Defined and enforced public v2 C++ coding style * #51: Added agent and contributor guidance for v1/v2 development, SLC workflows, CI testing, and PR conventions diff --git a/doc/developer_guide/slc.md b/doc/developer_guide/slc.md index f546424..096f1ab 100644 --- a/doc/developer_guide/slc.md +++ b/doc/developer_guide/slc.md @@ -20,8 +20,9 @@ export DOCKER_HOST="$(limactl list docker-udf-client \ ``` After setting `DOCKER_HOST`, run normal `exaslct` commands from the host. The -Lima template provides Bazel, Protobuf, ZeroMQ, SWIG, Python, Poetry, and the -native dependency environment used by the v1 build. +Lima template provides Bazel, Protobuf, ZeroMQ, SWIG, Python, Poetry, the LLVM +20/Mull mutation-testing toolchain, and the native dependency environment used +by the v1 build. Export the checked-in v1 flavor to a local archive with: diff --git a/doc/developer_guide/v2/v2.md b/doc/developer_guide/v2/v2.md index 6936f9b..d037ef0 100644 --- a/doc/developer_guide/v2/v2.md +++ b/doc/developer_guide/v2/v2.md @@ -3,7 +3,7 @@ The v2 developer documentation is split into focused guides: - [Build and test](v2_build_and_test.md) — Bazel setup and test execution. -- [Code quality](v2_code_quality.md) — clang-tidy and clang-format checks. +- [Code quality](v2_code_quality.md) — clang-tidy, clang-format, and Mull mutation checks. - [Coverage](v2_coverage.md) — local LCOV and HTML coverage reports. - [Coding style](v2_coding_style.md) — public C++ conventions for v2 code. - [Fuzzing](v2_fuzzing.md) — libFuzzer targets, Nox campaigns, and regression diff --git a/doc/developer_guide/v2/v2_code_quality.md b/doc/developer_guide/v2/v2_code_quality.md index 864d574..5dd443f 100644 --- a/doc/developer_guide/v2/v2_code_quality.md +++ b/doc/developer_guide/v2/v2_code_quality.md @@ -81,3 +81,66 @@ alias( tags = ["noclangtidy"], ) ``` + +## Mutation testing with Mull + +Mutation testing for the functional v2 C++ tests uses [Mull](https://mull-project.com/) +with the pinned Mull 0.34.1 release and matching LLVM 20 toolchain. The +`docker-udf-client` Lima template provisions these tools automatically; on +other environments install the LLVM 20 compiler and `mull-20`, then verify +that `mull-runner-20` and `/usr/lib/mull-ir-frontend-20` are available. + +Run the mutation session from the repository root: + +```bash +poetry run -- nox --sessions=mull +``` + +If Bazel reports stale or incompatible Mull output from a previous VM or host +build, clean the Mull-specific output roots and reports before retrying: + +```bash +poetry run -- nox --sessions=mull-clean +``` + +If the Bazel executable is named `bazelisk`, run: +`BAZEL=bazelisk poetry run -- nox --sessions=mull`. + +The session discovers Bazel `cc_test` targets and runs each eligible target with +Mull instrumentation. It writes reports to `.build_output/mull/` and enforces +an 80% mutation-score threshold for every target that produces at least one +mutant. Targets for which Mull produces no mutants emit a warning and succeed; +the warning is shown in local Nox output and as a GitHub Actions annotation. +Build failures, test failures, invalid reports, and mutation scores below 80% +remain errors. The LLVM major version can be changed with `MULL_LLVM_VERSION`; +custom tool paths can be supplied with `MULL_CXX`, `MULL_RUNNER`, and +`MULL_IR_FRONTEND`. The C compiler used by Bazel can be overridden with +`MULL_CC`. + +### Viewing Mull HTML reports + +Mull writes an HTML page and its matching JSON data file for each target to +`.build_output/mull/`. The HTML page loads the JSON file dynamically, so serve +the directory over HTTP instead of opening the page directly with `file://`: + +```bash +cd .build_output/mull +python3 -m http.server 8000 +``` + +Then open `http://localhost:8000/.html`, for example +`http://localhost:8000/event_fd_test.html`. The page also loads the Mutation +Testing Elements JavaScript from its configured CDN and therefore needs network +access. + +The Mull workflow uploads these files as the +`mull-reports-` artifact. Download and extract the artifact, start the +same HTTP server in the extracted directory, and open the target's HTML page in +your browser. + +Mutation testing is not reliable for C++ template implementations or tests +that only exercise third-party dependencies. Keep those tests in normal Bazel +test coverage and exclude them from Mull with the `no-mull` tag. Production +implementation units with Mull-compatible non-template code should have a +dedicated test target that remains in the mutation matrix. + diff --git a/ext/lima_vm_templates/docker-udf-client.yaml b/ext/lima_vm_templates/docker-udf-client.yaml index 724f585..1b5da96 100644 --- a/ext/lima_vm_templates/docker-udf-client.yaml +++ b/ext/lima_vm_templates/docker-udf-client.yaml @@ -24,7 +24,7 @@ minimumLimaVersion: 1.1.0 base: -- template://_images/ubuntu-lts +- template://_images/ubuntu-24.04 - template://_default/mounts # containerd is managed by Docker, not by Lima, so the values are set to false here. @@ -58,17 +58,28 @@ provision: curl -fsSL https://get.docker.com | sh - mode: system script: | + #!/bin/bash + set -eux -o pipefail export DEBIAN_FRONTEND=noninteractive + curl -1sLf 'https://dl.cloudsmith.io/public/mull-project/mull-stable/setup.deb.sh' | bash apt update && apt install -y protobuf-compiler libzmq3-dev openjdk-17-jdk build-essential git python3.12-dev python3-pip libpcre3-dev clang-tidy-20 lcov + apt install -y clang-20 mull-20=0.34.1 + mull-runner-20 --version + test -x /usr/lib/mull-ir-frontend-20 curl -L https://github.com/bazelbuild/bazelisk/releases/download/v1.27.0/bazelisk-linux-amd64 -o /usr/bin/bazel chmod +x /usr/bin/bazel pip install --break-system-packages numpy curl -L -o swig-2.0.4.tar.gz https://exasol-script-languages-dependencies.s3.eu-central-1.amazonaws.com/swig-2.0.4.tar.gz && tar zxf swig-2.0.4.tar.gz && (cd swig-2.0.4 && ./configure --prefix=/usr && make && make install) && rm -rf swig-2.0.4 swig-2.0.4.tar.gz - curl -sSL https://install.python-poetry.org | POETRY_HOME=/usr/local/bin/poetry python3 - + poetry_home=/opt/poetry + if [ ! -x "$poetry_home/bin/poetry" ]; then + curl -sSL https://install.python-poetry.org | POETRY_HOME="$poetry_home" python3 - --version 2.3.0 + fi + ln -sfn "$poetry_home/bin/poetry" /usr/local/bin/poetry + /usr/local/bin/poetry --version - mode: system script: | #!/bin/sh - echo 'export PATH="/usr/local/bin/poetry/bin:$PATH"' > /etc/profile.d/poetry.sh + echo 'export PATH="/opt/poetry/bin:$PATH"' > /etc/profile.d/poetry.sh chmod +x /etc/profile.d/poetry.sh probes: - script: | diff --git a/mull.yml b/mull.yml new file mode 100644 index 0000000..6eadcef --- /dev/null +++ b/mull.yml @@ -0,0 +1,11 @@ +# Static Mull configuration. The Nox session prepends Bazel-generated +# includePaths before passing this file to Mull. +excludePaths: + - "(^|.*/).*_test\\.(cc|cpp)" + - "(^|.*/).*_benchmark\\.cc" + - "(^|.*/)third_party/.*" + - ".*bazel-out/.*" + +parallelization: + workers: 2 + executionWorkers: 2 diff --git a/noxfile.py b/noxfile.py index f073f3c..701ac3e 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,14 +1,18 @@ import argparse +from dataclasses import dataclass import json import nox import os from packaging.version import InvalidVersion, Version from pathlib import Path +import re +import shutil import subprocess from exasol.slc_ci_setup.nox.tasks import * ROOT = Path(__file__).parent +MULL_MUTATION_SCORE_THRESHOLD = 80 # default actions to be run if nothing is explicitly specified with the -s option @@ -175,6 +179,414 @@ def run_oft_for_udf_client(session: nox.Session, *args) -> None: ) +@nox.session(name="mull-targets", python=False) +def list_mull_targets_session(session: nox.Session): + """Expose Mull target discovery as a Nox session for CI.""" + _write_mull_matrix(session) + + +@nox.session(name="mull-clean", python=False) +def clean_mull_session(session: nox.Session): + """Remove Mull reports and stale Bazel output roots.""" + bazel = os.environ.get("BAZEL", "bazel") + v2_root = ROOT / "udf-runner-cpp" / "v2" + report_dir = ROOT / ".build_output" / "mull" + configured_root = Path( + os.environ.get("MULL_BAZEL_OUTPUT_ROOT", ROOT / ".build_output" / "bazel-mull") + ) + output_roots = (configured_root, v2_root / ".build_output" / "bazel-mull") + + for output_root in dict.fromkeys(output_roots): + if not output_root.exists(): + continue + _validate_mull_cleanup_path(output_root, configured_root, v2_root) + with session.chdir(v2_root): + session.run( + bazel, + f"--output_user_root={output_root}", + "shutdown", + external=True, + ) + shutil.rmtree(output_root) + + if report_dir.exists(): + shutil.rmtree(report_dir) + + +def _validate_mull_cleanup_path(path: Path, configured_root: Path, v2_root: Path) -> None: + """Reject cleanup paths that could remove unrelated user data.""" + resolved_path = path.resolve() + repository_root = ROOT.resolve() + current_root = (ROOT / ".build_output" / "bazel-mull").resolve() + legacy_root = (v2_root / ".build_output" / "bazel-mull").resolve() + if resolved_path in {current_root, legacy_root}: + return + if path == configured_root and os.environ.get("MULL_BAZEL_OUTPUT_ROOT"): + if resolved_path in {Path("/"), Path.home(), Path("/tmp"), repository_root}: + raise ValueError(f"Refusing to remove unsafe Mull output root: {resolved_path}") + if not resolved_path.name.startswith("bazel-mull"): + raise ValueError( + "MULL_BAZEL_OUTPUT_ROOT must name a bazel-mull directory when using " + "the mull-clean session" + ) + return + raise ValueError(f"Refusing to remove unexpected Mull output root: {resolved_path}") + + +def _write_mull_matrix(session: nox.Session): + """List Mull targets and optionally write a GitHub Actions matrix.""" + parser = argparse.ArgumentParser(usage=f"nox -s {session.name} -- [options]") + parser.add_argument( + "--github-output-var", + help="write the matrix JSON to this variable in GITHUB_OUTPUT", + ) + args = parser.parse_args(session.posargs) + + matrix = json.dumps({"target": list(_get_mull_targets(session))}, separators=(",", ":")) + if args.github_output_var: + github_output = os.environ.get("GITHUB_OUTPUT") + if not github_output: + session.error("GITHUB_OUTPUT is required with --github-output-var") + with open(github_output, "a") as output: + output.write(f"{args.github_output_var}={matrix}\n") + else: + print(matrix) + + +def _get_mull_targets(session: nox.Session) -> tuple[str, ...]: + """Discover Bazel cc_test targets not explicitly excluded from Mull.""" + v2_root = ROOT / "udf-runner-cpp" / "v2" + bazel = os.environ.get("BAZEL", "bazel") + bazel_startup_args = [] + if output_user_root := os.environ.get("MULL_BAZEL_OUTPUT_ROOT"): + bazel_startup_args.append(f"--output_user_root={output_user_root}") + with session.chdir(v2_root): + labels = session.run( + bazel, + *bazel_startup_args, + "query", + 'kind("cc_test rule", //...) except attr("tags", "no-mull", //...)', + "--output=label", + silent=True, + external=True, + ) + + targets = tuple( + sorted( + label.rsplit(":", maxsplit=1)[1] + for label in labels.splitlines() + if label.startswith("//:") and ":" in label + ) + ) + if not targets: + session.error("No Bazel cc_test targets available for Mull were found") + return targets + + +@dataclass(frozen=True) +class _MullToolchain: + bazel: str + compiler: str + c_compiler: str + runner: str + frontend: Path + + +def _get_mull_toolchain(session: nox.Session) -> _MullToolchain: + llvm_version = os.environ.get("MULL_LLVM_VERSION", "20") + bazel = os.environ.get("BAZEL", "bazel") + compiler = os.environ.get("MULL_CXX", f"clang++-{llvm_version}") + c_compiler = os.environ.get("MULL_CC", compiler.replace("clang++", "clang", 1)) + runner = os.environ.get("MULL_RUNNER", f"mull-runner-{llvm_version}") + frontend = Path( + os.environ.get("MULL_IR_FRONTEND", f"/usr/lib/mull-ir-frontend-{llvm_version}") + ) + + required_tools = [bazel, c_compiler, compiler, runner] + missing_tools = [tool for tool in required_tools if shutil.which(tool) is None] + if missing_tools: + session.error( + "Mull requires these executable(s) on PATH: " + + ", ".join(missing_tools) + + ". Install the matching LLVM/Mull toolchain or override MULL_CXX " + "and MULL_RUNNER." + ) + if not frontend.exists(): + session.error( + f"Mull IR frontend does not exist: {frontend}. " + "Override MULL_IR_FRONTEND with the version-matched plugin path." + ) + return _MullToolchain(bazel, compiler, c_compiler, runner, frontend) + + +def _get_mull_paths() -> tuple[Path, Path, Path]: + v2_root = ROOT / "udf-runner-cpp" / "v2" + report_dir = ROOT / ".build_output" / "mull" + report_dir.mkdir(parents=True, exist_ok=True) + bazel_output_root = Path( + os.environ.get("MULL_BAZEL_OUTPUT_ROOT", ROOT / ".build_output" / "bazel-mull") + ) + return v2_root, report_dir, bazel_output_root + + +def _generate_mull_config( + session: nox.Session, + toolchain: _MullToolchain, + targets: list[str], + v2_root: Path, + bazel_output_root: Path, + generated_config: Path, +) -> None: + session.run( + "python", + str(ROOT / "tools" / "generate_mull_config.py"), + "--bazel", + toolchain.bazel, + "--output-user-root", + str(bazel_output_root), + "--v2-root", + str(v2_root), + "--template", + str(ROOT / "mull.yml"), + "--output", + str(generated_config), + *sum((["--target", target] for target in targets), []), + ) + + +def _build_mull_targets( + session: nox.Session, + toolchain: _MullToolchain, + targets: list[str], + bazel_startup_args: list[str], + generated_config: Path, + run_env: dict[str, str], +) -> Path: + bazel_args = [ + "build", + "--compilation_mode=dbg", + "--copt=-O0", + "--copt=-g", + "--copt=-grecord-command-line", + f"--copt=-fpass-plugin={toolchain.frontend}", + f"--action_env=MULL_CONFIG={generated_config}", + "--per_file_copt=.*\\.c$@-std=gnu11", + f"--repo_env=CC={toolchain.c_compiler}", + f"--repo_env=CXX={toolchain.compiler}", + "--verbose_failures", + *targets, + ] + if build_jobs := os.environ.get("MULL_BAZEL_BUILD_JOBS"): + bazel_args.insert(1, f"--jobs={build_jobs}") + + session.run(toolchain.bazel, *bazel_startup_args, *bazel_args, env=run_env) + return Path( + session.run( + toolchain.bazel, + *bazel_startup_args, + "info", + "bazel-bin", + "--compilation_mode=dbg", + silent=True, + external=True, + ).strip() + ) + + +def _get_mull_library_search_args(bazel_bin: Path) -> list[str]: + library_paths = sorted(bazel_bin.glob("_solib_*")) + library_paths.extend( + path + for path in ( + Path("/lib64"), + Path("/lib/x86_64-linux-gnu"), + Path("/usr/lib/x86_64-linux-gnu"), + ) + if path.is_dir() + ) + return [argument for path in library_paths for argument in ("--ld-search-path", str(path))] + + +@dataclass(frozen=True) +class _MullMutationResult: + killed: int + total: int + + @property + def score(self) -> float: + return self.killed * 100 / self.total + + +def _get_mull_mutant_total(report: object) -> int | None: + if not isinstance(report, dict) or not isinstance(report.get("files"), dict): + return None + + total = 0 + for file_report in report["files"].values(): + if not isinstance(file_report, dict) or not isinstance(file_report.get("mutants"), list): + return None + for mutant in file_report["mutants"]: + if not isinstance(mutant, dict) or not isinstance(mutant.get("status"), str): + return None + total += len(file_report["mutants"]) + + return total + + +def _parse_mull_mutation_result(output: str, report: object) -> _MullMutationResult | None: + total = _get_mull_mutant_total(report) + if total is None: + return None + if total == 0: + return _MullMutationResult(0, 0) + + killed = [ + (int(killed), int(reported_total)) + for killed, reported_total in re.findall( + r"Killed mutants \((\d+)/(\d+)\)", output + ) + ] + if killed: + killed_count, reported_total = max(killed, key=lambda count: count[1]) + if reported_total != total: + return None + return _MullMutationResult(killed_count, total) + + survived = [ + (int(survived), int(reported_total)) + for survived, reported_total in re.findall( + r"Survived mutants \((\d+)/(\d+)\)", output + ) + ] + if survived: + survived_count, reported_total = max(survived, key=lambda count: count[1]) + if reported_total != total: + return None + return _MullMutationResult(total - survived_count, total) + + if re.search(r"Surviving mutants:\s*\d+", output, re.IGNORECASE): + return _MullMutationResult(0, total) + + return None + + +def _warn_about_zero_mutants(target_name: str, report_file: Path) -> None: + message = ( + f"Mull target '{target_name}' produced no mutants; mutation coverage is unavailable " + f"(report: {report_file})" + ) + print(f"WARNING: {message}") + if os.environ.get("GITHUB_ACTIONS", "").lower() == "true": + escaped_message = message.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + print(f"::warning title=Mull mutation testing::{escaped_message}") + + +def _run_mull_target( + session: nox.Session, + toolchain: _MullToolchain, + target: str, + report_dir: Path, + library_search_args: list[str], + run_env: dict[str, str], +) -> None: + target_name = target.rsplit(":", maxsplit=1)[1] + executable = Path("bazel-bin") / target_name + if not executable.exists(): + session.error(f"Bazel did not produce expected test binary: {executable}") + mull_output = session.run( + toolchain.runner, + *library_search_args, + "--ide-reporter-show-killed", + "--reporters", + "IDE", + "--reporters", + "Elements", + "--report-dir", + str(report_dir), + "--report-name", + target_name, + executable, + env=run_env, + silent=True, + success_codes=(0, 1), + ) + report_file = report_dir / f"{target_name}.txt" + elements_report_file = report_dir / f"{target_name}.json" + output = mull_output if isinstance(mull_output, str) else "" + if not report_file.exists() and re.search(r"No mutants found", output, re.IGNORECASE): + _warn_about_zero_mutants(target_name, report_file) + return + if not report_file.exists(): + session.error(f"Mull did not produce the expected report: {report_file}") + if not elements_report_file.exists(): + session.error(f"Mull did not produce the expected Elements report: {elements_report_file}") + if isinstance(mull_output, str): + print(mull_output, end="") + try: + elements_report = json.loads(elements_report_file.read_text()) + except json.JSONDecodeError as error: + session.error(f"Could not parse Mull Elements report '{elements_report_file}': {error}") + mutation_result = _parse_mull_mutation_result( + f"{mull_output or ''}\n{report_file.read_text()}", elements_report + ) + if mutation_result is None: + session.error(f"Could not parse Mull mutation results for target '{target_name}'") + if mutation_result.total == 0: + _warn_about_zero_mutants(target_name, report_file) + return + if mutation_result.killed * 100 < MULL_MUTATION_SCORE_THRESHOLD * mutation_result.total: + session.error( + f"Mull target '{target_name}' mutation score is " + f"{mutation_result.score:.1f}%, below the " + f"{MULL_MUTATION_SCORE_THRESHOLD}% threshold" + ) + + +@nox.session(name="mull", python=False) +def run_mull(session: nox.Session): + """Run Mull mutation testing for the functional v2 C++ tests.""" + parser = argparse.ArgumentParser(usage=f"nox -s {session.name} -- [options]") + parser.add_argument("--target") + args = parser.parse_args(session.posargs) + + toolchain = _get_mull_toolchain(session) + v2_root, report_dir, bazel_output_root = _get_mull_paths() + + target_names = (args.target,) if args.target else _get_mull_targets(session) + targets = [f"//:{target}" for target in target_names] + bazel_startup_args = [f"--output_user_root={bazel_output_root}"] + generated_config = report_dir / "mull.yml" + run_env = os.environ.copy() + run_env["MULL_CONFIG"] = str(generated_config) + + with session.chdir(v2_root): + _generate_mull_config( + session, + toolchain, + targets, + v2_root, + bazel_output_root, + generated_config, + ) + bazel_bin = _build_mull_targets( + session, + toolchain, + targets, + bazel_startup_args, + generated_config, + run_env, + ) + library_search_args = _get_mull_library_search_args(bazel_bin) + for target in targets: + _run_mull_target( + session, + toolchain, + target, + report_dir, + library_search_args, + run_env, + ) + @nox.session(name="run-oft", python=False) def run_oft_udf_client_plaintext(session: nox.Session): """ diff --git a/test_mull_reporting.py b/test_mull_reporting.py new file mode 100644 index 0000000..708de60 --- /dev/null +++ b/test_mull_reporting.py @@ -0,0 +1,88 @@ +import os +import unittest +from pathlib import Path +from unittest.mock import patch + +from noxfile import ( + _MullMutationResult, + _parse_mull_mutation_result, + _warn_about_zero_mutants, +) + + +class MullMutationReportingTest(unittest.TestCase): + def test_parses_killed_mutants(self): + self.assertEqual( + _parse_mull_mutation_result( + "[info] Killed mutants (4/4):", + {"files": {"example.cc": {"mutants": [{"status": "Timeout"}] * 4}}}, + ), + _MullMutationResult(4, 4), + ) + + def test_parses_non_killed_mutants(self): + self.assertEqual( + _parse_mull_mutation_result( + "[info] Survived mutants (2/5):", + { + "files": { + "example.cc": { + "mutants": [ + {"status": "Killed"}, + {"status": "Killed"}, + {"status": "Killed"}, + {"status": "Survived"}, + {"status": "Timeout"}, + ] + } + } + } + ), + _MullMutationResult(3, 5), + ) + + def test_parses_zero_mutants(self): + self.assertEqual( + _parse_mull_mutation_result( + "[info] No mutants found", {"files": {"example.cc": {"mutants": []}}} + ), + _MullMutationResult(0, 0), + ) + + def test_parses_surviving_mutants_summary(self): + self.assertEqual( + _parse_mull_mutation_result( + "[info] Surviving mutants: 8", + {"files": {"example.cc": {"mutants": [{"status": "Survived"}] * 8}}}, + ), + _MullMutationResult(0, 8), + ) + + def test_rejects_malformed_report(self): + self.assertIsNone( + _parse_mull_mutation_result("Mull exited successfully", {"files": {"example.cc": {}}}) + ) + + def test_warns_locally_without_github_annotation(self): + with ( + patch.dict(os.environ, {"GITHUB_ACTIONS": "false"}), + patch("builtins.print") as print_mock, + ): + _warn_about_zero_mutants("example_test", Path("report.txt")) + + print_mock.assert_called_once() + self.assertTrue(print_mock.call_args.args[0].startswith("WARNING:")) + + def test_emits_github_annotation(self): + with ( + patch.dict(os.environ, {"GITHUB_ACTIONS": "true"}), + patch("builtins.print") as print_mock, + ): + _warn_about_zero_mutants("example_test", Path("report.txt")) + + self.assertEqual(print_mock.call_count, 2) + self.assertTrue(print_mock.call_args.args[0].startswith("::warning")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/generate_mull_config.py b/tools/generate_mull_config.py new file mode 100644 index 0000000..2e2d639 --- /dev/null +++ b/tools/generate_mull_config.py @@ -0,0 +1,116 @@ +import argparse +import json +from pathlib import Path, PurePosixPath +import re +import subprocess + + +SOURCE_SUFFIXES = {".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx"} +EXCLUDED_PATH_PARTS = {"external", "bazel-out", "third_party"} + + +def _artifact_path(path_fragments: dict[int, dict], fragment_id: int) -> str: + parts = [] + while fragment_id: + fragment = path_fragments[fragment_id] + parts.append(fragment["label"]) + fragment_id = fragment.get("parentId", 0) + return "/".join(reversed(parts)) + + +def _source_paths(query_result: dict) -> tuple[str, ...]: + path_fragments = { + fragment["id"]: fragment for fragment in query_result.get("pathFragments", []) + } + artifacts = { + artifact["id"]: _artifact_path(path_fragments, artifact["pathFragmentId"]) + for artifact in query_result.get("artifacts", []) + } + dep_sets = { + dep_set["id"]: dep_set for dep_set in query_result.get("depSetOfFiles", []) + } + + def artifact_ids(dep_set_id: int, seen: set[int]) -> set[int]: + if dep_set_id in seen: + return set() + seen.add(dep_set_id) + dep_set = dep_sets[dep_set_id] + result = set(dep_set.get("directArtifactIds", [])) + for transitive_id in dep_set.get("transitiveDepSetIds", []): + result.update(artifact_ids(transitive_id, seen)) + return result + + input_ids = set() + for action in query_result.get("actions", []): + for dep_set_id in action.get("inputDepSetIds", []): + input_ids.update(artifact_ids(dep_set_id, set())) + + paths = set() + for artifact_id in input_ids: + path = artifacts[artifact_id] + path_parts = set(PurePosixPath(path).parts) + if path_parts & EXCLUDED_PATH_PARTS: + continue + if re.search(r"(?:_test|_benchmark)\.(?:c|cc|cpp|cxx|h|hh|hpp|hxx)$", path): + continue + if Path(path).suffix in SOURCE_SUFFIXES: + paths.add(path) + return tuple(sorted(paths)) + + +def _query_compile_inputs( + bazel: str, output_user_root: Path, v2_root: Path, targets: tuple[str, ...] +) -> tuple[str, ...]: + target_set = " ".join(targets) + query = f'mnemonic("CppCompile", deps(set({target_set})))' + command = [ + bazel, + f"--output_user_root={output_user_root}", + "aquery", + "--output=jsonproto", + query, + ] + result = subprocess.run( + command, + cwd=v2_root, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "Bazel aquery failed") + return _source_paths(json.loads(result.stdout)) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--bazel", default="bazel") + parser.add_argument("--output-user-root", type=Path, required=True) + parser.add_argument("--v2-root", type=Path, required=True) + parser.add_argument("--template", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--target", action="append", required=True) + args = parser.parse_args() + + paths = _query_compile_inputs( + args.bazel, + args.output_user_root, + args.v2_root, + tuple(args.target), + ) + include_paths = "includePaths:\n" + if paths: + include_paths += "".join( + f" - {json.dumps(r'(^|.*/)' + re.escape(path) + r'$')}\n" + for path in paths + ) + else: + # Keep Mull from falling back to scanning every file when a target has + # no Mull-compatible production source after filtering. + include_paths += " - \"(?!)\"\n" + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(include_paths + "\n" + args.template.read_text()) + + +if __name__ == "__main__": + main() diff --git a/udf-runner-cpp/v2/BUILD.bazel b/udf-runner-cpp/v2/BUILD.bazel index 7a578f1..fbca2d0 100644 --- a/udf-runner-cpp/v2/BUILD.bazel +++ b/udf-runner-cpp/v2/BUILD.bazel @@ -93,10 +93,25 @@ cc_library( deps = [":private_flatbuffers_runtime"], ) +cc_library( + name = "mutation_smoke", + srcs = ["mutation_smoke.cc"], + hdrs = ["mutation_smoke.hpp"], + copts = ["-std=c++23"], +) + +cc_test( + name = "mutation_smoke_test", + srcs = ["mutation_smoke_test.cc"], + copts = ["-std=c++23"], + deps = [":mutation_smoke"], +) + cc_test( name = "udf_protocol_test", srcs = ["udf_protocol_test.cc"], copts = ["-std=c++23"], + tags = ["no-mull"], deps = [ ":udf_protocol", "@googletest//:gtest_main", @@ -115,6 +130,7 @@ cc_test( name = "udf_protocol_symbol_leak_test", srcs = ["nm_runner.hpp", "udf_protocol_symbol_leak_test.cc"], copts = ["-std=c++23"], + tags = ["no-mull"], # The test inspects the built shared object with nm. data makes the # artifact available at runtime and args passes its runfiles path. data = [":udf_protocol_shared"], @@ -127,6 +143,7 @@ cc_test( name = "udf_protocol_static_symbol_leak_test", srcs = ["nm_runner.hpp", "udf_protocol_static_symbol_leak_test.cc"], copts = ["-std=c++23"], + tags = ["no-mull"], # cc_library produces multiple artifacts, so the test receives all # locations and selects the static .a archive for nm inspection. data = [":udf_protocol"], @@ -142,6 +159,7 @@ cc_test( "flatbuffers_header_order_reverse.cc", ], copts = ["-std=c++23"], + tags = ["no-mull"], # Compile both include orders against the ordinary runtime and the # isolated runtime to protect against header-guard and macro collisions. deps = [":udf_protocol", "@flatbuffers//:runtime_cc"], @@ -182,6 +200,7 @@ cc_test( name = "arrow_core_test", srcs = ["arrow_core_test.cc"], copts = ["-std=c++23"], + tags = ["no-mull"], deps = [ ":arrow_core", "@googletest//:gtest_main", @@ -192,6 +211,7 @@ cc_test( name = "arrow_c_data_demo_test", srcs = ["arrow_c_data_demo_test.cc"], copts = ["-std=c++23"], + tags = ["no-mull"], data = [":libarrow_c_data_demo.so"], args = ["$(location :libarrow_c_data_demo.so)"], linkopts = ["-ldl"], @@ -212,6 +232,7 @@ cc_test( srcs = ["json_schema_validation_test.cc"], data = ["//json_schema:all_schemas"], copts = ["-std=c++23"], + tags = ["no-mull"], deps = [ ":json_schema", "@googletest//:gtest_main", @@ -222,6 +243,7 @@ cc_test( name = "json_schema_symbol_leak_test", srcs = ["json_schema_symbol_leak_test.cc"], copts = ["-std=c++23"], + tags = ["no-mull"], linkopts = ["-ldl"], target_compatible_with = ["@platforms//os:linux"], deps = [":json_schema"], @@ -299,6 +321,7 @@ cc_test( name = "moodycamel_queues_test", srcs = ["moodycamel_queues_test.cc"], copts = ["-std=c++23"], + tags = ["no-mull"], deps = [ ":moodycamel_queues", "@googletest//:gtest_main", @@ -317,6 +340,7 @@ cc_test( name = "moodycamel_symbol_leak_test", srcs = ["moodycamel_symbol_leak_test.cc"], copts = ["-std=c++23"], + tags = ["no-mull"], data = [":moodycamel_queues_shared"], args = ["$(location :moodycamel_queues_shared)"], target_compatible_with = ["@platforms//os:linux"], @@ -325,19 +349,37 @@ cc_test( cc_library( name = "event_fd", - srcs = ["event_fd.cc"], hdrs = ["include/exasol/udf/v2/event_fd.hpp"], includes = ["include"], copts = ["-std=c++23"], target_compatible_with = ["@platforms//os:linux"], ) +cc_library( + name = "linux_event_fd", + srcs = ["linux_event_fd.cc"], + hdrs = ["include/exasol/udf/v2/linux_event_fd.hpp"], + includes = ["include"], + deps = [":event_fd"], + target_compatible_with = ["@platforms//os:linux"], +) + +cc_library( + name = "event_fd_factory", + srcs = ["event_fd_factory.cc"], + hdrs = ["include/exasol/udf/v2/event_fd_factory.hpp"], + includes = ["include"], + deps = [":linux_event_fd"], + target_compatible_with = ["@platforms//os:linux"], +) + cc_test( name = "event_fd_test", srcs = ["event_fd_test.cc"], copts = ["-std=c++23"], deps = [ - ":event_fd", + ":event_fd_factory", + ":linux_event_fd", "@googletest//:gtest_main", ], target_compatible_with = ["@platforms//os:linux"], @@ -355,6 +397,18 @@ cc_library( target_compatible_with = ["@platforms//os:linux"], ) +cc_library( + name = "linux_waitable_queue", + hdrs = ["include/exasol/udf/v2/linux_waitable_queue.hpp"], + includes = ["include"], + copts = ["-std=c++23"], + deps = [ + ":event_fd_factory", + ":waitable_queue", + ], + target_compatible_with = ["@platforms//os:linux"], +) + cc_test( name = "waitable_queue_test", srcs = ["waitable_queue_test.cc"], @@ -372,10 +426,24 @@ cc_test( srcs = ["waitable_queue_integration_test.cc"], copts = ["-std=c++23"], deps = [ - ":waitable_queue", + ":linux_waitable_queue", + "@googletest//:gtest_main", + ], + tags = ["no-coverage", "no-mull"], + target_compatible_with = ["@platforms//os:linux"], +) + +cc_test( + name = "linux_waitable_queue_test", + srcs = ["linux_waitable_queue_test.cc"], + copts = ["-std=c++23"], + deps = [ + ":linux_waitable_queue", "@googletest//:gtest_main", ], - tags = ["no-coverage"], + # The factory functions under test are templates; per the developer + # guide, mutation testing is unreliable for template implementations. + tags = ["no-mull"], target_compatible_with = ["@platforms//os:linux"], ) @@ -384,7 +452,7 @@ cc_binary( srcs = ["waitable_queue_benchmark.cc"], copts = ["-std=c++23"], deps = [ - ":waitable_queue", + ":linux_waitable_queue", "@google_benchmark//:benchmark_main", ], target_compatible_with = ["@platforms//os:linux"], @@ -395,7 +463,7 @@ cc_fuzz_test( srcs = ["queue_fuzz_test.cc"], corpus = glob(["fuzz/corpus/queue/**"]), copts = ["-std=c++23"], - deps = [":waitable_queue"], + deps = [":linux_waitable_queue"], tags = ["fuzz-test"], target_compatible_with = ["@platforms//os:linux"], ) diff --git a/udf-runner-cpp/v2/event_fd_factory.cc b/udf-runner-cpp/v2/event_fd_factory.cc new file mode 100644 index 0000000..7ae3ea1 --- /dev/null +++ b/udf-runner-cpp/v2/event_fd_factory.cc @@ -0,0 +1,13 @@ +#include + +#include + +namespace exasol::udf::v2 +{ + +std::unique_ptr make_linux_event_fd() +{ + return std::make_unique(); +} + +} // namespace exasol::udf::v2 diff --git a/udf-runner-cpp/v2/event_fd_test.cc b/udf-runner-cpp/v2/event_fd_test.cc index 613cd63..72bac65 100644 --- a/udf-runner-cpp/v2/event_fd_test.cc +++ b/udf-runner-cpp/v2/event_fd_test.cc @@ -1,10 +1,13 @@ #include +#include #include #include #include #include +#include +#include #include namespace @@ -32,6 +35,13 @@ void self_move_assign(Type& value) (value.*assign)(std::move(value)); } +void expect_closed_descriptor(int file_descriptor) +{ + errno = 0; + EXPECT_EQ(::close(file_descriptor), -1); + EXPECT_EQ(errno, EBADF); +} + } // namespace TEST(EventFdTest, AccumulatesNotifications) @@ -44,6 +54,15 @@ TEST(EventFdTest, AccumulatesNotifications) EXPECT_EQ(event_fd.read_notification(), 2); } +TEST(EventFdTest, FactoryCreatesLinuxEventFd) +{ + auto event_fd = exasol::udf::v2::make_linux_event_fd(); + ASSERT_NE(event_fd, nullptr); + ASSERT_NE(event_fd->native_handle(), -1); + event_fd->write_notification(); + EXPECT_EQ(event_fd->read_notification(), 1); +} + TEST(EventFdTest, RejectsReadWhenEmpty) { exasol::udf::v2::LinuxEventFd event_fd; @@ -71,6 +90,28 @@ TEST(EventFdTest, SupportsMoveAssignmentAndSelfMove) EXPECT_EQ(move_assigned.native_handle(), moved_handle); } +TEST(EventFdTest, MoveAssignmentClosesReplacedDescriptor) +{ + exasol::udf::v2::LinuxEventFd source; + exasol::udf::v2::LinuxEventFd destination; + const int replaced_handle = destination.native_handle(); + + destination = std::move(source); + + expect_closed_descriptor(replaced_handle); +} + +TEST(EventFdTest, DestructorClosesDescriptor) +{ + int destroyed_handle = -1; + { + exasol::udf::v2::LinuxEventFd event_fd; + destroyed_handle = event_fd.native_handle(); + } + + expect_closed_descriptor(destroyed_handle); +} + TEST(EventFdTest, RejectsReadOnClosedDescriptor) { exasol::udf::v2::LinuxEventFd closed_event_fd; diff --git a/udf-runner-cpp/v2/include/exasol/udf/v2/event_fd.hpp b/udf-runner-cpp/v2/include/exasol/udf/v2/event_fd.hpp index afa1b2d..cc22beb 100644 --- a/udf-runner-cpp/v2/include/exasol/udf/v2/event_fd.hpp +++ b/udf-runner-cpp/v2/include/exasol/udf/v2/event_fd.hpp @@ -1,9 +1,5 @@ #pragma once -#if !defined(__linux__) -#error "exasol::udf::v2::LinuxEventFd requires Linux eventfd" -#endif - #include namespace exasol::udf::v2 @@ -15,8 +11,8 @@ namespace exasol::udf::v2 class EventFd { public: - EventFd() = default; - virtual ~EventFd(); + EventFd() = default; + virtual ~EventFd() = default; EventFd(const EventFd&) = delete; EventFd& operator=(const EventFd&) = delete; @@ -28,23 +24,4 @@ class EventFd virtual void write_notification() = 0; }; -class LinuxEventFd final : public EventFd -{ -public: - LinuxEventFd(); - ~LinuxEventFd() override; - - LinuxEventFd(const LinuxEventFd&) = delete; - LinuxEventFd& operator=(const LinuxEventFd&) = delete; - LinuxEventFd(LinuxEventFd&& other) noexcept; - LinuxEventFd& operator=(LinuxEventFd&& other) noexcept; - - [[nodiscard]] int native_handle() const noexcept override; - std::uint64_t read_notification() override; - void write_notification() override; - -private: - int file_descriptor = -1; -}; - } // namespace exasol::udf::v2 diff --git a/udf-runner-cpp/v2/include/exasol/udf/v2/event_fd_factory.hpp b/udf-runner-cpp/v2/include/exasol/udf/v2/event_fd_factory.hpp new file mode 100644 index 0000000..438b9a0 --- /dev/null +++ b/udf-runner-cpp/v2/include/exasol/udf/v2/event_fd_factory.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include + +#include + +namespace exasol::udf::v2 +{ + +std::unique_ptr make_linux_event_fd(); + +} // namespace exasol::udf::v2 diff --git a/udf-runner-cpp/v2/include/exasol/udf/v2/linux_event_fd.hpp b/udf-runner-cpp/v2/include/exasol/udf/v2/linux_event_fd.hpp new file mode 100644 index 0000000..15c6eed --- /dev/null +++ b/udf-runner-cpp/v2/include/exasol/udf/v2/linux_event_fd.hpp @@ -0,0 +1,33 @@ +#pragma once + +#if !defined(__linux__) +#error "exasol::udf::v2::LinuxEventFd requires Linux eventfd" +#endif + +#include + +#include + +namespace exasol::udf::v2 +{ + +class LinuxEventFd final : public EventFd +{ +public: + LinuxEventFd(); + ~LinuxEventFd() override; + + LinuxEventFd(const LinuxEventFd&) = delete; + LinuxEventFd& operator=(const LinuxEventFd&) = delete; + LinuxEventFd(LinuxEventFd&& other) noexcept; + LinuxEventFd& operator=(LinuxEventFd&& other) noexcept; + + [[nodiscard]] int native_handle() const noexcept override; + std::uint64_t read_notification() override; + void write_notification() override; + +private: + int file_descriptor = -1; +}; + +} // namespace exasol::udf::v2 diff --git a/udf-runner-cpp/v2/include/exasol/udf/v2/linux_waitable_queue.hpp b/udf-runner-cpp/v2/include/exasol/udf/v2/linux_waitable_queue.hpp new file mode 100644 index 0000000..d19d4b5 --- /dev/null +++ b/udf-runner-cpp/v2/include/exasol/udf/v2/linux_waitable_queue.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace exasol::udf::v2 +{ + +template +using WaitableSpscQueue = WaitableQueue>; + +template +using WaitableMpmcQueue = WaitableQueue>; + +template +[[nodiscard]] WaitableSpscQueue make_waitable_spsc_queue() +{ + return WaitableSpscQueue(SpscQueue{}, make_linux_event_fd()); +} + +template +[[nodiscard]] WaitableSpscQueue make_waitable_spsc_queue(SpscQueue queue) +{ + return WaitableSpscQueue(std::move(queue), make_linux_event_fd()); +} + +template +[[nodiscard]] WaitableMpmcQueue make_waitable_mpmc_queue() +{ + return WaitableMpmcQueue(MpmcQueue{}, make_linux_event_fd()); +} + +template +[[nodiscard]] WaitableMpmcQueue make_waitable_mpmc_queue(MpmcQueue queue) +{ + return WaitableMpmcQueue(std::move(queue), make_linux_event_fd()); +} + +} // namespace exasol::udf::v2 diff --git a/udf-runner-cpp/v2/include/exasol/udf/v2/waitable_queue.hpp b/udf-runner-cpp/v2/include/exasol/udf/v2/waitable_queue.hpp index 31b7b36..9e284c7 100644 --- a/udf-runner-cpp/v2/include/exasol/udf/v2/waitable_queue.hpp +++ b/udf-runner-cpp/v2/include/exasol/udf/v2/waitable_queue.hpp @@ -1,9 +1,5 @@ #pragma once -#if !defined(__linux__) -#error "exasol::udf::v2::WaitableQueue requires Linux eventfd" -#endif - #include #include #include @@ -12,8 +8,6 @@ #include #include -#include -#include namespace exasol::udf::v2 { @@ -24,15 +18,6 @@ class WaitableQueue public: using queue_type = Queue; - WaitableQueue() : WaitableQueue(Queue{}, std::make_unique()) - { - } - - explicit WaitableQueue(Queue queue) - : WaitableQueue(std::move(queue), std::make_unique()) - { - } - WaitableQueue(Queue queue, std::unique_ptr event_fd) : queue_storage(std::move(queue)), notification_fd(std::move(event_fd)) { @@ -173,10 +158,4 @@ class WaitableQueue std::unique_ptr notification_fd; }; -template -using WaitableSpscQueue = WaitableQueue>; - -template -using WaitableMpmcQueue = WaitableQueue>; - } // namespace exasol::udf::v2 diff --git a/udf-runner-cpp/v2/event_fd.cc b/udf-runner-cpp/v2/linux_event_fd.cc similarity index 96% rename from udf-runner-cpp/v2/event_fd.cc rename to udf-runner-cpp/v2/linux_event_fd.cc index 0e2e454..f7e9cd9 100644 --- a/udf-runner-cpp/v2/event_fd.cc +++ b/udf-runner-cpp/v2/linux_event_fd.cc @@ -1,4 +1,4 @@ -#include +#include #include #include @@ -10,8 +10,6 @@ namespace exasol::udf::v2 { -EventFd::~EventFd() = default; - LinuxEventFd::LinuxEventFd() : file_descriptor(::eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC)) { if (file_descriptor == -1) diff --git a/udf-runner-cpp/v2/linux_waitable_queue_test.cc b/udf-runner-cpp/v2/linux_waitable_queue_test.cc new file mode 100644 index 0000000..d5268dc --- /dev/null +++ b/udf-runner-cpp/v2/linux_waitable_queue_test.cc @@ -0,0 +1,108 @@ +#include +#include +#include + +#include +#include + +// These tests exercise the Linux waitable-queue factory functions directly +// (a real eventfd, no epoll/socket readiness checks), so they run under +// normal coverage instrumentation. The epoll/socketpair-based readiness +// tests live in waitable_queue_integration_test.cc, which is excluded from +// coverage because it depends on real OS I/O timing. + +namespace +{ + +template +void self_move_assign(Queue& queue) +{ + using move_assignment = Queue& (Queue::*)(Queue&&) noexcept; + const move_assignment assign = &Queue::operator=; + (queue.*assign)(std::move(queue)); +} + +} // namespace + +TEST(LinuxWaitableQueueTest, SpscFactoryDefaultConstructsQueue) +{ + auto queue = exasol::udf::v2::make_waitable_spsc_queue(); + ASSERT_TRUE(queue.enqueue(4)); + EXPECT_EQ(queue.drain_notifications(), 1); + + int value = 0; + ASSERT_TRUE(queue.try_dequeue(value)); + EXPECT_EQ(value, 4); +} + +TEST(LinuxWaitableQueueTest, SpscFactoryWrapsProvidedQueue) +{ + auto queue = exasol::udf::v2::make_waitable_spsc_queue(exasol::udf::v2::SpscQueue(4)); + ASSERT_TRUE(queue.enqueue(5)); + EXPECT_EQ(queue.drain_notifications(), 1); + + int value = 0; + ASSERT_TRUE(queue.try_dequeue(value)); + EXPECT_EQ(value, 5); +} + +TEST(LinuxWaitableQueueTest, MpmcSingleValueOperations) +{ + auto queue = exasol::udf::v2::make_waitable_mpmc_queue(); + ASSERT_TRUE(queue.enqueue(7)); + EXPECT_EQ(queue.drain_notifications(), 1); + + int value = 0; + ASSERT_TRUE(queue.try_dequeue(value)); + EXPECT_EQ(value, 7); +} + +TEST(LinuxWaitableQueueTest, MpmcBatchAndEmptyBatchOperations) +{ + auto queue = exasol::udf::v2::make_waitable_mpmc_queue(); + const std::vector batch{8, 9}; + EXPECT_EQ(queue.enqueue_batch(batch.begin(), batch.end()), batch.size()); + EXPECT_EQ(queue.drain_notifications(), 1); + + int value = 0; + for (int expected : batch) + { + ASSERT_TRUE(queue.try_dequeue(value)); + EXPECT_EQ(value, expected); + } + + const std::array empty_batch{}; + EXPECT_EQ(queue.enqueue_batch(empty_batch.begin(), empty_batch.end()), 0); + EXPECT_EQ(queue.drain_notifications(), 0); +} + +TEST(LinuxWaitableQueueTest, MpmcProvidesQueueAccess) +{ + auto queue = exasol::udf::v2::make_waitable_mpmc_queue(); + const auto& const_queue = queue; + EXPECT_EQ(&const_queue.queue(), &queue.queue()); +} + +TEST(LinuxWaitableQueueTest, MpmcSupportsMoves) +{ + auto moved_queue = exasol::udf::v2::make_waitable_mpmc_queue(); + const int moved_handle = moved_queue.native_handle(); + auto move_constructed = std::move(moved_queue); + EXPECT_EQ(move_constructed.native_handle(), moved_handle); + + auto move_assigned = exasol::udf::v2::make_waitable_mpmc_queue(); + move_assigned = std::move(move_constructed); + EXPECT_EQ(move_assigned.native_handle(), moved_handle); + self_move_assign(move_assigned); +} + +TEST(LinuxWaitableQueueTest, MpmcFactoryWrapsProvidedQueue) +{ + auto queue = exasol::udf::v2::make_waitable_mpmc_queue(exasol::udf::v2::MpmcQueue(4)); + ASSERT_TRUE(queue.enqueue(6)); + EXPECT_EQ(queue.drain_notifications(), 1); + + int value = 0; + ASSERT_TRUE(queue.try_dequeue(value)); + EXPECT_EQ(value, 6); +} diff --git a/udf-runner-cpp/v2/mutation_smoke.cc b/udf-runner-cpp/v2/mutation_smoke.cc new file mode 100644 index 0000000..bc067f0 --- /dev/null +++ b/udf-runner-cpp/v2/mutation_smoke.cc @@ -0,0 +1,15 @@ +#include "mutation_smoke.hpp" + +namespace exasol::udf::v2::mutation_smoke +{ + +int transform(int value) +{ + if (value < 0) + { + return -value; + } + return value + 1; +} + +} // namespace exasol::udf::v2::mutation_smoke diff --git a/udf-runner-cpp/v2/mutation_smoke.hpp b/udf-runner-cpp/v2/mutation_smoke.hpp new file mode 100644 index 0000000..a00f410 --- /dev/null +++ b/udf-runner-cpp/v2/mutation_smoke.hpp @@ -0,0 +1,8 @@ +#pragma once + +namespace exasol::udf::v2::mutation_smoke +{ + +int transform(int value); + +} // namespace exasol::udf::v2::mutation_smoke diff --git a/udf-runner-cpp/v2/mutation_smoke_test.cc b/udf-runner-cpp/v2/mutation_smoke_test.cc new file mode 100644 index 0000000..ec7ed8d --- /dev/null +++ b/udf-runner-cpp/v2/mutation_smoke_test.cc @@ -0,0 +1,10 @@ +#include "mutation_smoke.hpp" + +#include + +int main() +{ + assert(exasol::udf::v2::mutation_smoke::transform(-7) == 7); + assert(exasol::udf::v2::mutation_smoke::transform(0) == 1); + assert(exasol::udf::v2::mutation_smoke::transform(7) == 8); +} diff --git a/udf-runner-cpp/v2/queue_fuzz_test.cc b/udf-runner-cpp/v2/queue_fuzz_test.cc index 23936ea..a1c165b 100644 --- a/udf-runner-cpp/v2/queue_fuzz_test.cc +++ b/udf-runner-cpp/v2/queue_fuzz_test.cc @@ -12,7 +12,7 @@ #include #include -#include +#include namespace { @@ -194,15 +194,16 @@ void consume(Queue& queue, } } -template +template void run_queue(const std::type_identity, const std::bool_constant, const std::span operations, const std::size_t producer_count, const std::size_t consumer_count, - const bool preserve_order) + const bool preserve_order, + QueueFactory create_queue) { - Queue queue; + Queue queue = create_queue(); std::vector> produced(producer_count); std::vector> consumed(consumer_count); for (auto& values : produced) @@ -285,21 +286,24 @@ extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, const std::size_ { case 0: run_queue(std::type_identity>{}, - std::bool_constant{}, operation_span, 1, 1, true); + std::bool_constant{}, operation_span, 1, 1, true, + [] { return exasol::udf::v2::SpscQueue{}; }); break; case 1: run_queue(std::type_identity>{}, std::bool_constant{}, operation_span, 2 + producer_bit, - 2 + consumer_bit, false); + 2 + consumer_bit, false, [] { return exasol::udf::v2::MpmcQueue{}; }); break; case 2: run_queue(std::type_identity>{}, - std::bool_constant{}, operation_span, 1, 1, true); + std::bool_constant{}, operation_span, 1, 1, true, + [] { return exasol::udf::v2::make_waitable_spsc_queue(); }); break; case 3: run_queue(std::type_identity>{}, std::bool_constant{}, operation_span, 2 + producer_bit, - 2 + consumer_bit, false); + 2 + consumer_bit, false, + [] { return exasol::udf::v2::make_waitable_mpmc_queue(); }); break; default: fuzz_failure(); diff --git a/udf-runner-cpp/v2/sonar-project.properties b/udf-runner-cpp/v2/sonar-project.properties index ca17499..ea0d1fe 100644 --- a/udf-runner-cpp/v2/sonar-project.properties +++ b/udf-runner-cpp/v2/sonar-project.properties @@ -5,10 +5,12 @@ sonar.projectKey=udf-runner-cpp # in v2 while excluding vendored third-party sources. sonar.sources=. sonar.exclusions=third_party/**,bazel-*/** -# Assertion termination includes intentionally untestable abort paths and -# compiler-generated exception branches. Keep the files in Sonar analysis, +# Assertion termination, and the Linux eventfd/waitable-queue factory +# wrappers, include intentionally untestable abort paths and +# compiler-generated exception branches (cleanup code for an allocation or +# syscall failure inside a factory call). Keep the files in Sonar analysis, # but exclude them from the coverage gate. -sonar.coverage.exclusions=**/*_test.cc,**/json_schema_fuzzing.hpp,**/event_fd.cc,**/arrow_c_data_demo.cc,assert.cc,include/exasol/udf/v2/assert.hpp +sonar.coverage.exclusions=**/*_test.cc,**/*_test.cpp,**/*_fuzz_test.cc,**/*_benchmark.cc,**/json_schema_fuzzing.hpp,**/linux_event_fd.cc,**/arrow_c_data_demo.cc,assert.cc,include/exasol/udf/v2/assert.hpp,event_fd_factory.cc,include/exasol/udf/v2/linux_waitable_queue.hpp # Bazel's SonarQube coverage generator writes this generic coverage report. sonar.coverageReportPaths=bazel-out/_coverage/_coverage_report.dat diff --git a/udf-runner-cpp/v2/waitable_queue_benchmark.cc b/udf-runner-cpp/v2/waitable_queue_benchmark.cc index 9d22d5c..f69da78 100644 --- a/udf-runner-cpp/v2/waitable_queue_benchmark.cc +++ b/udf-runner-cpp/v2/waitable_queue_benchmark.cc @@ -14,7 +14,7 @@ #include #include -#include +#include namespace { @@ -58,7 +58,8 @@ void bm_raw_spsc_round_trip(benchmark::State& state) // write is part of the measured round trip. void bm_waitable_spsc_round_trip(benchmark::State& state) { - exasol::udf::v2::WaitableSpscQueue queue(exasol::udf::v2::SpscQueue(1024)); + auto queue = + exasol::udf::v2::make_waitable_spsc_queue(exasol::udf::v2::SpscQueue(1024)); for (const auto iteration : state) { benchmark::DoNotOptimize(&iteration); @@ -108,7 +109,8 @@ void bm_raw_spsc_enqueue_latency(benchmark::State& state) void bm_waitable_spsc_enqueue_latency(benchmark::State& state) { - exasol::udf::v2::WaitableSpscQueue queue(exasol::udf::v2::SpscQueue(1024)); + auto queue = + exasol::udf::v2::make_waitable_spsc_queue(exasol::udf::v2::SpscQueue(1024)); for (const auto iteration : state) { benchmark::DoNotOptimize(&iteration); @@ -169,7 +171,8 @@ void bm_waitable_spsc_batch(benchmark::State& state) { const auto batch_size = static_cast(state.range(0)); const std::vector batch(batch_size, 1); - exasol::udf::v2::WaitableSpscQueue queue{exasol::udf::v2::SpscQueue(batch_size)}; + auto queue = + exasol::udf::v2::make_waitable_spsc_queue(exasol::udf::v2::SpscQueue(batch_size)); for (const auto iteration : state) { @@ -192,7 +195,8 @@ void bm_waitable_spsc_batch(benchmark::State& state) // handshake is outside the manually recorded interval. void bm_waitable_spsc_epoll_latency(benchmark::State& state) { - exasol::udf::v2::WaitableSpscQueue queue{exasol::udf::v2::SpscQueue(8)}; + auto queue = exasol::udf::v2::make_waitable_spsc_queue( + exasol::udf::v2::SpscQueue(8)); const int epoll_fd = ::epoll_create1(EPOLL_CLOEXEC); benchmark_check(epoll_fd != -1, "epoll_create1 failed"); diff --git a/udf-runner-cpp/v2/waitable_queue_integration_test.cc b/udf-runner-cpp/v2/waitable_queue_integration_test.cc index 0bff948..b348834 100644 --- a/udf-runner-cpp/v2/waitable_queue_integration_test.cc +++ b/udf-runner-cpp/v2/waitable_queue_integration_test.cc @@ -8,7 +8,7 @@ #include #include -#include +#include #include namespace @@ -64,7 +64,8 @@ class SpscEpollTest : public testing::Test } private: - exasol::udf::v2::WaitableSpscQueue queue_storage; + exasol::udf::v2::WaitableSpscQueue queue_storage = + exasol::udf::v2::make_waitable_spsc_queue(); int epoll_fd_storage = ::epoll_create1(EPOLL_CLOEXEC); std::array sockets_storage{}; }; @@ -122,63 +123,13 @@ TEST_F(SpscEpollTest, SupportsQueueOperationsAndBatches) TEST_F(SpscEpollTest, SupportsMoves) { - exasol::udf::v2::WaitableSpscQueue moved_queue; + auto moved_queue = exasol::udf::v2::make_waitable_spsc_queue(); const int moved_handle = moved_queue.native_handle(); - exasol::udf::v2::WaitableSpscQueue move_constructed(std::move(moved_queue)); + auto move_constructed = std::move(moved_queue); EXPECT_EQ(move_constructed.native_handle(), moved_handle); - exasol::udf::v2::WaitableSpscQueue move_assigned; - move_assigned = std::move(move_constructed); - EXPECT_EQ(move_assigned.native_handle(), moved_handle); - self_move_assign(move_assigned); -} - -TEST(WaitableQueueIntegrationTest, MpmcSingleValueOperations) -{ - exasol::udf::v2::WaitableMpmcQueue queue; - ASSERT_TRUE(queue.enqueue(7)); - EXPECT_EQ(queue.drain_notifications(), 1); - - int value = 0; - ASSERT_TRUE(queue.try_dequeue(value)); - EXPECT_EQ(value, 7); -} - -TEST(WaitableQueueIntegrationTest, MpmcBatchAndEmptyBatchOperations) -{ - exasol::udf::v2::WaitableMpmcQueue queue; - const std::vector batch{8, 9}; - EXPECT_EQ(queue.enqueue_batch(batch.begin(), batch.end()), batch.size()); - EXPECT_EQ(queue.drain_notifications(), 1); - - int value = 0; - for (int expected : batch) - { - ASSERT_TRUE(queue.try_dequeue(value)); - EXPECT_EQ(value, expected); - } - - const std::array empty_batch{}; - EXPECT_EQ(queue.enqueue_batch(empty_batch.begin(), empty_batch.end()), 0); - EXPECT_EQ(queue.drain_notifications(), 0); -} - -TEST(WaitableQueueIntegrationTest, MpmcProvidesQueueAccess) -{ - exasol::udf::v2::WaitableMpmcQueue queue; - const auto& const_queue = queue; - EXPECT_EQ(&const_queue.queue(), &queue.queue()); -} - -TEST(WaitableQueueIntegrationTest, MpmcSupportsMoves) -{ - exasol::udf::v2::WaitableMpmcQueue moved_queue; - const int moved_handle = moved_queue.native_handle(); - exasol::udf::v2::WaitableMpmcQueue move_constructed(std::move(moved_queue)); - EXPECT_EQ(move_constructed.native_handle(), moved_handle); - - exasol::udf::v2::WaitableMpmcQueue move_assigned; - move_assigned = std::move(move_constructed); + auto move_assigned = exasol::udf::v2::make_waitable_spsc_queue(); + move_assigned = std::move(move_constructed); EXPECT_EQ(move_assigned.native_handle(), moved_handle); self_move_assign(move_assigned); }