|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | 3 | import ctypes |
| 4 | +import errno |
4 | 5 | import json |
5 | 6 | import os |
| 7 | +import struct |
6 | 8 | import subprocess |
7 | 9 | import sys |
8 | 10 | from pathlib import Path |
@@ -34,6 +36,105 @@ def test_process_metrics_are_available() -> None: |
34 | 36 | assert memory_backend() in {"peak_rss_fallback", "proc_rss", "windows_working_set"} |
35 | 37 |
|
36 | 38 |
|
| 39 | +@pytest.mark.parametrize( |
| 40 | + ("value", "time_enabled", "time_running", "expected"), |
| 41 | + [ |
| 42 | + (123, 1_000, 1_000, 123), |
| 43 | + (900, 1_000, 900, 1_000), |
| 44 | + ], |
| 45 | +) |
| 46 | +def test_instruction_count_normalizes_scheduled_time( |
| 47 | + value: int, |
| 48 | + time_enabled: int, |
| 49 | + time_running: int, |
| 50 | + expected: int, |
| 51 | +) -> None: |
| 52 | + assert metrics_module._normalize_instruction_count(value, time_enabled, time_running) == expected # noqa: SLF001 |
| 53 | + |
| 54 | + |
| 55 | +@pytest.mark.parametrize( |
| 56 | + ("value", "time_enabled", "time_running", "message"), |
| 57 | + [ |
| 58 | + (100, 1_000, 899, "heavily multiplexed"), |
| 59 | + (100, 1_000, 0, "not scheduled"), |
| 60 | + (0, 1_000, 1_000, "returned zero"), |
| 61 | + (100, 999, 1_000, "inconsistent timing"), |
| 62 | + ], |
| 63 | +) |
| 64 | +def test_instruction_count_rejects_unreliable_readings( |
| 65 | + value: int, |
| 66 | + time_enabled: int, |
| 67 | + time_running: int, |
| 68 | + message: str, |
| 69 | +) -> None: |
| 70 | + with pytest.raises(OSError, match=message): |
| 71 | + metrics_module._normalize_instruction_count(value, time_enabled, time_running) # noqa: SLF001 |
| 72 | + |
| 73 | + |
| 74 | +def test_instruction_counter_uses_per_measurement_scheduling_time(monkeypatch: pytest.MonkeyPatch) -> None: |
| 75 | + monkeypatch.setattr(metrics_module, "fcntl", SimpleNamespace(ioctl=mock.Mock())) |
| 76 | + monkeypatch.setattr( |
| 77 | + os, |
| 78 | + "read", |
| 79 | + mock.Mock( |
| 80 | + side_effect=[ |
| 81 | + struct.pack("=QQQ", 100, 1_000, 1_000), |
| 82 | + struct.pack("=QQQ", 90, 2_000, 1_900), |
| 83 | + ] |
| 84 | + ), |
| 85 | + ) |
| 86 | + counter = metrics_module._InstructionCounter(42) # noqa: SLF001 |
| 87 | + |
| 88 | + assert counter.stop() == 100 |
| 89 | + assert counter.stop() == 100 |
| 90 | + |
| 91 | + |
| 92 | +def test_instruction_calibration_failure_falls_back_to_process_time() -> None: |
| 93 | + instruction_counter = mock.Mock(spec=metrics_module._InstructionCounter) # noqa: SLF001 |
| 94 | + instruction_counter.stop.side_effect = OSError("perf hardware instruction counter returned zero") |
| 95 | + |
| 96 | + with mock.patch.object( |
| 97 | + metrics_module._InstructionCounter, # noqa: SLF001 |
| 98 | + "open", |
| 99 | + return_value=(instruction_counter, None), |
| 100 | + ): |
| 101 | + counter = CpuCounter() |
| 102 | + |
| 103 | + assert counter.backend == "process_time" |
| 104 | + assert counter.unavailable_reason == ( |
| 105 | + "perf hardware counter calibration failed: perf hardware instruction counter returned zero" |
| 106 | + ) |
| 107 | + instruction_counter.start.assert_called_once_with() |
| 108 | + instruction_counter.stop.assert_called_once_with() |
| 109 | + instruction_counter.close.assert_called_once_with() |
| 110 | + |
| 111 | + |
| 112 | +@pytest.mark.parametrize( |
| 113 | + ("error_number", "error_name"), |
| 114 | + [ |
| 115 | + (errno.EPERM, "EPERM"), |
| 116 | + (errno.EACCES, "EACCES"), |
| 117 | + ], |
| 118 | +) |
| 119 | +def test_perf_event_open_reason_preserves_errno( |
| 120 | + monkeypatch: pytest.MonkeyPatch, |
| 121 | + error_number: int, |
| 122 | + error_name: str, |
| 123 | +) -> None: |
| 124 | + syscall = mock.Mock(return_value=-1) |
| 125 | + libc = SimpleNamespace(syscall=syscall) |
| 126 | + monkeypatch.setattr(metrics_module, "_perf_event_syscall_number", lambda: 298) |
| 127 | + monkeypatch.setattr(metrics_module, "fcntl", SimpleNamespace()) |
| 128 | + monkeypatch.setattr(ctypes, "CDLL", lambda *_args, **_kwargs: libc) |
| 129 | + monkeypatch.setattr(ctypes, "get_errno", lambda: error_number) |
| 130 | + |
| 131 | + counter, reason = metrics_module._InstructionCounter.open() # noqa: SLF001 |
| 132 | + |
| 133 | + assert counter is None |
| 134 | + assert reason is not None |
| 135 | + assert f"errno={error_number} {error_name}" in reason |
| 136 | + |
| 137 | + |
37 | 138 | def test_runner_key_prefers_override_then_gitlab_identity(monkeypatch: pytest.MonkeyPatch) -> None: |
38 | 139 | monkeypatch.setenv("CI_RUNNER_ID", "runner-123") |
39 | 140 | monkeypatch.setenv("CI_RUNNER_DESCRIPTION", "shared-medium-host") |
@@ -102,6 +203,12 @@ def test_plugin_writes_one_current_artifact(tmp_path: Path) -> None: |
102 | 203 | assert record["outcome"] == "passed" |
103 | 204 | assert record["metrics"]["cpu_time_ns"] > 0 |
104 | 205 | assert payload["environment"]["counter_backend"] in {"perf_event_open", "process_time"} |
| 206 | + unavailable_reason = payload["environment"]["cpu_instructions_unavailable_reason"] |
| 207 | + if payload["environment"]["counter_backend"] == "process_time": |
| 208 | + assert isinstance(unavailable_reason, str) |
| 209 | + assert unavailable_reason |
| 210 | + else: |
| 211 | + assert unavailable_reason is None |
105 | 212 | assert payload["environment"]["cpu_time_scope"] == "pytest_call_process" |
106 | 213 | assert payload["environment"]["memory_scope"] == "process_rss_boundaries" |
107 | 214 | assert payload["environment"]["runner_key"] == "runner-123" |
@@ -133,6 +240,32 @@ def test_xdist_workers_are_merged_into_one_artifact(tmp_path: Path) -> None: |
133 | 240 | assert payload["job_metrics"]["cpu_time_ns"] == sum(test["metrics"]["cpu_time_ns"] for test in payload["tests"]) |
134 | 241 |
|
135 | 242 |
|
| 243 | +def test_xdist_environment_merges_counter_backends_and_reasons() -> None: |
| 244 | + environment = plugin_module._merge_environments( # noqa: SLF001 |
| 245 | + [ |
| 246 | + { |
| 247 | + "counter_backend": "perf_event_open", |
| 248 | + "cpu_instructions_scope": "current_thread", |
| 249 | + "cpu_instructions_unavailable_reason": None, |
| 250 | + }, |
| 251 | + { |
| 252 | + "counter_backend": "process_time", |
| 253 | + "cpu_instructions_scope": "unavailable", |
| 254 | + "cpu_instructions_unavailable_reason": "errno=1 EPERM", |
| 255 | + }, |
| 256 | + { |
| 257 | + "counter_backend": "process_time", |
| 258 | + "cpu_instructions_scope": "unavailable", |
| 259 | + "cpu_instructions_unavailable_reason": "counter returned zero", |
| 260 | + }, |
| 261 | + ] |
| 262 | + ) |
| 263 | + |
| 264 | + assert environment["counter_backend"] == "mixed" |
| 265 | + assert environment["cpu_instructions_scope"] == "mixed" |
| 266 | + assert environment["cpu_instructions_unavailable_reason"] == "counter returned zero | errno=1 EPERM" |
| 267 | + |
| 268 | + |
136 | 269 | def test_source_fingerprint_is_scoped_to_the_test_function(tmp_path: Path) -> None: |
137 | 270 | first_artifact = tmp_path / "first.json" |
138 | 271 | second_artifact = tmp_path / "second.json" |
|
0 commit comments