test(a11y): stabilize inactive performance benchmark and CI coverage

- Warm the notification workload before timing one million calls
- Collect ten fixed enabled/disabled pairs with alternating order
- Preserve the 1.01x ratio-of-medians guard without conditional retries
- Validate reported measurements and retain JSON diagnostics on failure
- Prevent D-Bus service output from contaminating benchmark artifacts
- Run native AT-SPI checks after timing failures without masking CI failure
- Add deterministic runner tests and document the measurement protocol
This commit is contained in:
Martín Lucas Golini
2026-10-14 18:12:23 -03:00
parent 110a9d46f0
commit f809e84b53
5 changed files with 277 additions and 48 deletions
@@ -2,6 +2,7 @@
import argparse
import json
import math
import os
from pathlib import Path
import statistics
@@ -36,6 +37,8 @@ def run_sample(args, disabled):
"--benchmark-inactive",
"--benchmark-iterations",
str(args.iterations),
"--benchmark-warmup-iterations",
str(args.warmup_iterations),
],
check=True,
capture_output=True,
@@ -45,9 +48,22 @@ def run_sample(args, disabled):
)
wall_ms = (time.perf_counter() - start) * 1000
result = json.loads(completed.stdout)
check(
result.get("iterations") == args.iterations
and result.get("warmup_iterations") == args.warmup_iterations,
"executable did not report the requested measurement and warmup iterations",
)
for key in ("initialization_us", "readiness_us", "warmup_us", "notifications_us"):
value = result.get(key)
check(
isinstance(value, (int, float)) and math.isfinite(value) and value >= 0,
f"invalid or missing {key} measurement",
)
check(result["notifications_us"] > 0, "notification measurement was zero")
result["wall_ms"] = wall_ms
result["initialization_ms"] = result.pop("initialization_us") / 1000
result["readiness_ms"] = result.pop("readiness_us") / 1000
result["warmup_ms"] = result.pop("warmup_us") / 1000
result["notifications_ms"] = result.pop("notifications_us") / 1000
return result
@@ -59,8 +75,8 @@ def median_sample(samples):
}
def collect_samples(args, first_index, count, enabled_samples, disabled_samples):
for index in range(first_index, first_index + count):
def collect_samples(args, enabled_samples, disabled_samples):
for index in range(args.samples):
# Reverse each adjacent pair so neither mode consistently benefits from running second.
modes = (True, False) if index % 2 == 0 else (False, True)
for disabled in modes:
@@ -83,61 +99,58 @@ def summarize(enabled_samples, disabled_samples):
}
def main():
def create_parser():
parser = argparse.ArgumentParser(description="Benchmark inactive accessibility overhead")
parser.add_argument("--executable", default="bin/eepp-ui-accessibility")
parser.add_argument("--iterations", type=int, default=100000)
parser.add_argument("--samples", type=int, default=5)
parser.add_argument("--confirmation-samples", type=int, default=10)
parser.add_argument("--iterations", type=int, default=1000000,
help="measured setText calls per process (roughly one second on the CI runner)")
parser.add_argument("--warmup-iterations", type=int, default=100000,
help="untimed setText calls before each measurement")
parser.add_argument("--samples", type=int, default=10,
help="fixed number of adjacent enabled/disabled sample pairs")
parser.add_argument("--timeout", type=float, default=10)
parser.add_argument("--max-initialization-ms", type=float, default=250)
parser.add_argument("--max-notification-ratio", type=float, default=1.01)
parser.add_argument("--max-wall-ratio", type=float, default=1.25)
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
return parser
def main(argv=None):
args = create_parser().parse_args(argv)
check(args.iterations > 0 and args.samples > 0, "iterations and samples must be positive")
check(args.confirmation_samples >= 0, "confirmation samples must not be negative")
check(args.warmup_iterations >= 0, "warmup iterations must not be negative")
for key in ("timeout", "max_initialization_ms", "max_notification_ratio", "max_wall_ratio"):
value = getattr(args, key)
check(math.isfinite(value) and value > 0, f"{key} must be finite and positive")
enabled_samples = []
disabled_samples = []
collect_samples(args, 0, args.samples, enabled_samples, disabled_samples)
# The sample count is independent of the result: no early pass or retry-until-green.
collect_samples(args, enabled_samples, disabled_samples)
output = summarize(enabled_samples, disabled_samples)
if (
output["notification_ratio"] > args.max_notification_ratio
or output["wall_ratio"] > args.max_wall_ratio
) and args.confirmation_samples:
collect_samples(
args,
args.samples,
args.confirmation_samples,
enabled_samples,
disabled_samples,
)
output = summarize(enabled_samples, disabled_samples)
output["sample_pairs"] = args.samples
enabled = output["enabled"]
failed = (
enabled["initialization_ms"] > args.max_initialization_ms
or output["notification_ratio"] > args.max_notification_ratio
or output["wall_ratio"] > args.max_wall_ratio
)
if failed:
print(json.dumps(output, sort_keys=True), file=sys.stderr)
check(
enabled["initialization_ms"] <= args.max_initialization_ms,
"inactive native backend initialization exceeded threshold",
)
check(
output["notification_ratio"] <= args.max_notification_ratio,
"inactive accessibility notification overhead exceeded threshold",
)
check(
output["wall_ratio"] <= args.max_wall_ratio,
"inactive accessibility wall-time overhead exceeded threshold",
)
failures = []
for value, limit, message in (
(enabled["initialization_ms"], args.max_initialization_ms,
"inactive native backend initialization exceeded threshold"),
(output["notification_ratio"], args.max_notification_ratio,
"inactive accessibility notification overhead exceeded threshold"),
(output["wall_ratio"], args.max_wall_ratio,
"inactive accessibility wall-time overhead exceeded threshold"),
):
if value > limit:
failures.append(message)
output["failures"] = failures
# Preserve the complete JSON artifact even when a guard rejects this run.
if args.json:
print(json.dumps(output, sort_keys=True))
else:
print(json.dumps(output, sort_keys=True), flush=True)
elif failures:
print(json.dumps(output, sort_keys=True), file=sys.stderr)
check(not failures, "; ".join(failures))
if not args.json:
print(
"Inactive accessibility benchmark passed: "
f"initialization {enabled['initialization_ms']:.3f}ms, "
@@ -0,0 +1,173 @@
"""Deterministic runner tests; no graphics, D-Bus or benchmark timings are required."""
import contextlib
import importlib.util
import io
import json
from pathlib import Path
import subprocess
import unittest
from unittest import mock
SCRIPT = Path(__file__).resolve().parents[1] / "benchmark_accessibility_inactive.py"
SPEC = importlib.util.spec_from_file_location("benchmark_accessibility_inactive", SCRIPT)
benchmark = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(benchmark)
def sample(notifications_ms=1000):
return {
"initialization_ms": 0.02,
"readiness_ms": 1,
"warmup_ms": 100,
"notifications_ms": notifications_ms,
"wall_ms": notifications_ms + 100,
"iterations": 1000000,
"warmup_iterations": 100000,
}
def executable_report():
return {
"initialization_us": 20,
"readiness_us": 1000,
"warmup_us": 100000,
"notifications_us": 1000000,
"iterations": 1000000,
"warmup_iterations": 100000,
}
class InactiveBenchmarkTests(unittest.TestCase):
def test_defaults_preserve_strict_guard_with_long_fixed_workload(self):
args = benchmark.create_parser().parse_args([])
self.assertEqual(args.iterations, 1000000)
self.assertEqual(args.warmup_iterations, 100000)
self.assertEqual(args.samples, 10)
self.assertEqual(args.max_notification_ratio, 1.01)
self.assertFalse(hasattr(args, "confirmation_samples"))
def test_pairs_reverse_order_without_losing_pair_alignment(self):
args = benchmark.create_parser().parse_args(["--samples", "4"])
enabled, disabled, calls = [], [], []
def run(args, is_disabled):
calls.append(is_disabled)
return {"pair": (len(calls) - 1) // 2}
with mock.patch.object(benchmark, "run_sample", side_effect=run):
benchmark.collect_samples(args, enabled, disabled)
self.assertEqual(calls, [True, False, False, True, True, False, False, True])
self.assertEqual(enabled, [{"pair": n} for n in range(4)])
self.assertEqual(disabled, enabled)
def test_guard_still_uses_ratio_of_medians(self):
enabled = [sample(v) for v in (102, 99, 100)]
disabled = [sample(v) for v in (100, 101, 140)]
output = benchmark.summarize(enabled, disabled)
self.assertEqual(output["notification_ratio"], 100 / 101)
def test_fixed_count_on_both_pass_and_failure_and_failure_json_survives(self):
for ratio in (1.0, 1.02):
with self.subTest(ratio=ratio):
output = io.StringIO()
def run(args, disabled):
return sample(1000 if disabled else 1000 * ratio)
with mock.patch.object(benchmark, "run_sample", side_effect=run) as runner:
with contextlib.redirect_stdout(output):
if ratio > 1.01:
with self.assertRaisesRegex(RuntimeError, "notification overhead"):
benchmark.main(["--json"])
else:
benchmark.main(["--json"])
self.assertEqual(runner.call_count, 20)
report = json.loads(output.getvalue())
self.assertEqual(report["sample_pairs"], 10)
self.assertEqual(len(report["raw_enabled"]), 10)
self.assertEqual(len(report["raw_disabled"]), 10)
self.assertEqual(bool(report["failures"]), ratio > 1.01)
self.assertEqual(report["notification_ratio"], ratio)
def test_exact_threshold_is_allowed(self):
def run(args, disabled):
return sample(1000 if disabled else 1010)
with mock.patch.object(benchmark, "run_sample", side_effect=run):
with contextlib.redirect_stdout(io.StringIO()):
benchmark.main(["--json", "--samples", "2"])
def test_all_guards_remain_failures(self):
def run(args, disabled):
result = sample(1000 if disabled else 1500)
if not disabled:
result["initialization_ms"] = 251
return result
output = io.StringIO()
with mock.patch.object(benchmark, "run_sample", side_effect=run):
with contextlib.redirect_stdout(output):
with self.assertRaises(RuntimeError):
benchmark.main(["--json", "--samples", "2"])
self.assertEqual(len(json.loads(output.getvalue())["failures"]), 3)
def test_warmup_argument_and_disabled_environment_are_forwarded(self):
args = benchmark.create_parser().parse_args([])
completed = subprocess.CompletedProcess([], 0, json.dumps(executable_report()), "")
for disabled in (False, True):
with self.subTest(disabled=disabled):
with mock.patch.dict(benchmark.os.environ, {"EEPP_DISABLE_ACCESSIBILITY": "1"}):
with mock.patch.object(benchmark.subprocess, "run", return_value=completed) as run:
with mock.patch.object(benchmark.time, "perf_counter", side_effect=(10, 11.25)):
result = benchmark.run_sample(args, disabled)
command = run.call_args.args[0]
self.assertEqual(command[-2:], ["--benchmark-warmup-iterations", "100000"])
self.assertEqual("EEPP_DISABLE_ACCESSIBILITY" in run.call_args.kwargs["env"], disabled)
self.assertTrue(run.call_args.kwargs["check"])
self.assertEqual(result["warmup_ms"], 100)
self.assertEqual(result["wall_ms"], 1250)
def test_stale_binary_or_invalid_duration_is_rejected(self):
args = benchmark.create_parser().parse_args([])
cases = [
("warmup_iterations", None),
("iterations", 100000),
("warmup_us", None),
("notifications_us", 0),
("notifications_us", float("nan")),
("initialization_us", float("inf")),
("readiness_us", -1),
]
for key, value in cases:
with self.subTest(key=key, value=value):
report = executable_report()
report[key] = value
completed = subprocess.CompletedProcess([], 0, json.dumps(report), "")
with mock.patch.object(benchmark.subprocess, "run", return_value=completed):
with self.assertRaises(RuntimeError):
benchmark.run_sample(args, False)
def test_invalid_arguments_do_not_launch_a_process(self):
for option, value in (
("--iterations", "0"), ("--samples", "0"),
("--warmup-iterations", "-1"), ("--timeout", "0"),
("--max-notification-ratio", "nan"), ("--max-wall-ratio", "inf"),
):
with self.subTest(option=option):
with mock.patch.object(benchmark, "run_sample") as run:
with self.assertRaises(RuntimeError):
benchmark.main([option, value])
run.assert_not_called()
def test_failed_executable_is_not_turned_into_a_timing_result(self):
args = benchmark.create_parser().parse_args([])
with mock.patch.object(benchmark.subprocess, "run",
side_effect=subprocess.CalledProcessError(1, "benchmark")):
with self.assertRaises(subprocess.CalledProcessError):
benchmark.run_sample(args, False)
if __name__ == "__main__":
unittest.main()