diff --git a/scripts/nvx_tools/doctor.py b/scripts/nvx_tools/doctor.py index 42973e6fa..c305d14ac 100644 --- a/scripts/nvx_tools/doctor.py +++ b/scripts/nvx_tools/doctor.py @@ -718,7 +718,13 @@ def _window_rate(points: Sequence[tuple[int, int, float]], clock_hz: int) -> flo def check_rate(context: DoctorContext) -> CheckResult: clocksource = "" if not host_is_windows(): - clocksource = host_clocksource() + try: + clocksource = host_clocksource() + except OSError as error: + # The host clocksource is recorded as evidence only; an + # unreadable file must not fail the check. Record it explicitly + # and still run the TSC rate/stability measurements below. + clocksource = f"unknown ({error})" context.facts["host_clocksource"] = clocksource schedule = context.schedule # The probe sleeps between samples for the whole window, 120 s on the diff --git a/scripts/test_time_abi.py b/scripts/test_time_abi.py index f4d58b5a5..15441fb2f 100644 --- a/scripts/test_time_abi.py +++ b/scripts/test_time_abi.py @@ -2631,6 +2631,62 @@ def ci_context(backend: str = "kvm") -> doctor.DoctorContext: self.assertTrue(standalone.passed, standalone.detail) self.assertIn("not compared with the backend's rate", standalone.detail) + def test_rate_check_continues_when_the_clocksource_is_unreadable(self): + # #507: the host clocksource is evidence only, so an unreadable + # sysfs file must not fail H4; the native probe still runs and the + # verdict depends on the measurements alone. + def records(gaps: tuple[int, int]) -> list[tuple[str, dict[str, str]]]: + result: list[tuple[str, dict[str, str]]] = [ + ( + "clock", + { + "role": role, + "name": name, + "hz": "1000000000", + "resolution_ns": "1.000", + }, + ) + for role, name in ( + ("rate", "CLOCK_MONOTONIC"), + ("stability", "CLOCK_MONOTONIC_RAW"), + ) + ] + tsc = 0 + for index in range(3): + sample = {"index": str(index)} + for role in ("rate", "stability"): + sample[f"{role}_tsc"] = str(tsc) + sample[f"{role}_clock"] = str(index * 1_000_000_000) + sample[f"{role}_bracket"] = "20" + tsc += gaps[min(index, 1)] + result.append(("sample", sample)) + return result + + error = FileNotFoundError(2, "No such file or directory", "/sys/fake") + for gaps, passed, marker in ( + ((3_000_000_000, 3_000_000_000), True, "host clocksource unknown"), + ((3_000_000_000, 3_100_000_000), False, "interval rates differ"), + ): + with self.subTest(passed=passed): + context = doctor_context(self.root) + context.schedule = doctor.CI_SCHEDULE + with ( + patch.object( + doctor, "run_probe", return_value=records(gaps) + ) as run, + patch.object(doctor, "host_clocksource", side_effect=error), + patch.object(doctor, "host_is_windows", return_value=False), + ): + result = doctor.check_rate(context) + self.assertTrue(run.called) + self.assertEqual(result.passed, passed, result.detail) + self.assertIn(marker, result.detail) + self.assertIn("evidence", result.detail) + self.assertEqual( + context.facts["host_clocksource"], + f"unknown ({error})", + ) + def test_host_skew_check_applies_the_bound(self): summary = { "pairs": "28",