Viewing files at commit 5717cc9fc7edCommit detailsView latest

Browse files
suites/robustness/run_robustness.py 431 lines · 24.0 KB · python
1
#!/usr/bin/env python3
2
"""P4 robustness suite runner: crash points, fault injection, concurrency, env faults.
3
4
Writes robustness-lane JSONL rows (one per case) through oakbench.ResultsStore
5
into ``--results`` (default results/robustness, never committed, ADR-0003),
6
flushing each row to a .partial.jsonl as it completes, and prints a Markdown
7
classification table. Every case is classified held / violated / unsupported /
8
harness_error (see README.md). Loopback servers and local fixtures only.
9
10
Examples:
11
  OAK_BENCH_OAK_READER_PYTHON=/path/to/py python3 suites/robustness/run_robustness.py --quick
12
  python3 suites/robustness/run_robustness.py --subjects oak,git --families crash_time,proxy
13
"""
14
15
from __future__ import annotations
16
17
import argparse
18
import json
19
import platform
20
import shutil
21
import sys
22
import tempfile
23
import time
24
from datetime import datetime, timezone
25
from pathlib import Path
26
from typing import Any, Callable, Optional
27
28
sys.path.insert(0, str(Path(__file__).resolve().parent))
29
30
from robustness import (CLASSIFICATIONS, FAULT_NOT_FIRED, HARNESS_ERROR, HELD, SUITE_VERSION, UNSUPPORTED,  # noqa: E402
31
                        VIOLATED, known_bugs)
32
from robustness import crashpoints  # noqa: E402
33
from robustness.crash import (TIME_FRACTIONS, calibrate, run_time_point, run_trial, syscall_fault,  # noqa: E402
34
                              syscall_points)
35
from robustness.fixtures import FIXTURE_VERSION, build_git, build_oak  # noqa: E402
36
from robustness.procs import free_port  # noqa: E402
37
38
from oakbench.results import ResultsStore  # noqa: E402
39
from oakbench.rows import SKIP_RETURNCODE  # noqa: E402
40
from oakbench.subjects import binary_sha256, which_measured_git  # noqa: E402
41
42
LANE = "robustness"
43
FAMILIES = ("crash_time", "crash_syscall", "proxy", "concurrency", "env", "hosted")
44
CRASH_OPS = ("commit", "push", "pull_ff", "pull_diverged", "clone", "switch", "merge_local", "merge_hosted",
45
             "mount_finish")
46
PROXY_OPS = ("push", "pull_ff", "pull_diverged", "clone")
47
SCENARIO = {
48
    "crash_time": "crash_time_v1",
49
    "crash_syscall": "crash_syscall_v1",
50
    "proxy": "fault_proxy_v1",
51
}
52
RETURNCODE = {HELD: 0, VIOLATED: 1, HARNESS_ERROR: 2, UNSUPPORTED: SKIP_RETURNCODE, FAULT_NOT_FIRED: SKIP_RETURNCODE}
53
MEASUREMENT_SOURCE = "independent_reader_invariants_v1"
54
55
UNSUPPORTED_OPS = {
56
    ("oak", "merge_local"): "oak has no local branch-to-branch merge; `oak merge` is the hosted squash-merge (see merge_hosted)",
57
    ("oak", "merge_hosted"): "loopback oak serve 0.105.0 answers merge publication with 404; hosted merge needs a local oakspace (hosted_merge_invariants_v1)",
58
    ("git", "merge_hosted"): "git has no hosted merge; the git differential is merge_local",
59
    ("oak", "mount_finish"): "oak mount needs macOS FSKit (signed Oak Mount app) or Linux FUSE; not exercised against loopback serve in suite v1",
60
    ("git", "mount_finish"): "git has no mount",
61
}
62
63
64
class Runner:
65
    def __init__(self, args: argparse.Namespace):
66
        self.args = args
67
        self.bench_id = args.bench_id or datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
68
        self.work = Path(args.workdir or tempfile.mkdtemp(prefix="p4-robustness-")).resolve()
69
        self.work.mkdir(parents=True, exist_ok=True)
70
        self.results = Path(args.results).resolve()
71
        self.evidence = self.results / f"{self.bench_id}.robustness-evidence"
72
        self.store = ResultsStore(self.results, lane=LANE, filename_suffix="robustness", flush_partial=True)
73
        self.rows: list[dict] = []
74
        self.run_index: dict[tuple, int] = {}
75
        self.retained = 0
76
        self.bins = {"oak": args.oak_bin, "git": args.git_bin}
77
        self.instrument = None
78
        self.instrument_reason = ""
79
80
    # rows ----------------------------------------------------------------------
81
    def emit(self, subject_kind: str, scenario: str, operation: str, family: str, data: dict) -> dict:
82
        key = (subject_kind, scenario, operation)
83
        run = self.run_index.get(key, 0)
84
        self.run_index[key] = run + 1
85
        classification = data.get("classification", UNSUPPORTED if "unsupported" in data else HARNESS_ERROR)
86
        binary = self.bins.get(subject_kind)
87
        row: dict[str, Any] = {
88
            "bench_id": self.bench_id,
89
            "suite_version": SUITE_VERSION,
90
            "scenario": scenario,
91
            "operation": operation,
92
            "run": run,
93
            "subject": "oak_installed" if subject_kind == "oak" else "git",
94
            "subject_kind": subject_kind,
95
            "subject_bin": binary,
96
            "binary_sha256": binary_sha256(Path(binary)) if binary and Path(binary).exists() else None,
97
            "subject_version": self.versions.get(subject_kind),
98
            "fault_family": family,
99
            "fault_point": data.get("fault_point", "n/a"),
100
            "fault_fired": data.get("fault_fired", data.get("observations", {}).get("fault_fired")),
101
            "crash_model": "process_kill" if family.startswith("crash") or family == "proxy" else None,
102
            "classification": classification,
103
            "returncode": RETURNCODE[classification],
104
            "elapsed_ms": float(data.get("elapsed_ms", 0.0) or 0.0),
105
            "invariants": data.get("invariants", {}),
106
            "violations": data.get("violations", sorted(k for k, v in data.get("invariants", {}).items() if v is False)),
107
            "observations": data.get("observations", {}),
108
            "command": data.get("command", []),
109
            "fixture_version": FIXTURE_VERSION,
110
            "measurement_source": MEASUREMENT_SOURCE,
111
            "host_platform": platform.platform(),
112
        }
113
        if classification in (UNSUPPORTED, FAULT_NOT_FIRED):
114
            # Skip rows: a coverage gap (unsupported) or a clean run whose fault never
115
            # took effect (fault_not_fired). Neither is a measured result.
116
            row.update(skipped=True, skip_reason=data.get("unsupported") or data.get("skip_reason") or classification)
117
            row.pop("measurement_source")
118
        bugs = known_bugs(subject_kind, scenario, operation, row["violations"]) if classification == VIOLATED else []
119
        if bugs:
120
            row["known_bugs"] = bugs
121
        if data.get("unattributed_failure"):
122
            row["unattributed_failure"] = True
123
        if data.get("free_bytes_measured") is not None:
124
            row["free_bytes_measured"] = data["free_bytes_measured"]
125
        if data.get("harness_error"):
126
            row["harness_error"] = data["harness_error"]
127
        transport = data.get("remote_transport")
128
        if transport:
129
            row.update(remote_transport=transport, remote_server=data.get("remote_server"),
130
                       remote_repo=data.get("remote_repo"))
131
        if self.instrument is not None and family == "crash_syscall":
132
            row["crash_instrument"] = self.instrument.provenance
133
        self.rows.append(row)
134
        self.store.append_partial(self.bench_id, [row])
135
        mark = {HELD: ".", VIOLATED: "V", HARNESS_ERROR: "E", UNSUPPORTED: "u", FAULT_NOT_FIRED: "n"}[classification]
136
        print(mark, end="", flush=True)
137
        if classification in (VIOLATED, HARNESS_ERROR):
138
            print(f"\n  [{classification}] {subject_kind} {scenario} {operation} {row['fault_point']} "
139
                  f"{row['violations']} {row.get('harness_error', '')}", flush=True)
140
        return row
141
142
    def keep(self, trial, classification: str) -> None:
143
        trial.stop_server()
144
        if classification in (VIOLATED, HARNESS_ERROR) and self.retained < self.args.max_evidence:
145
            self.retained += 1
146
            dest = self.evidence / f"{self.retained:03d}-{trial.dir.name}"
147
            dest.parent.mkdir(parents=True, exist_ok=True)
148
            shutil.move(str(trial.dir), str(dest))
149
        else:
150
            shutil.rmtree(trial.dir, ignore_errors=True)
151
152
    def transport(self, kind: str, family: str) -> dict:
153
        if kind == "oak":
154
            return {"remote_transport": "network", "remote_server": "loopback-oak-serve", "remote_repo": "p4/robust"}
155
        if family == "proxy":
156
            return {"remote_transport": "network", "remote_server": "loopback-git-http-backend",
157
                    "remote_repo": "remote.git"}
158
        return {"remote_transport": "local_file", "remote_server": "local", "remote_repo": "../srv/remote.git"}
159
160
    # families --------------------------------------------------------------------
161
    def fixture(self, kind: str, op: str, tag: str):
162
        root = self.work / "fixtures" / f"{tag}-{kind}-{op}"
163
        if kind == "oak":
164
            return build_oak(self.bins["oak"], op, root, free_port())
165
        return build_git(self.bins["git"], op, root, free_port())
166
167
    def crash_family(self, kind: str, families: list[str]) -> None:
168
        ops = [op for op in self.args.ops if op in CRASH_OPS]
169
        for op in ops:
170
            if (kind, op) in UNSUPPORTED_OPS:
171
                for fam in families:
172
                    self.emit(kind, SCENARIO[fam], op, fam, {"unsupported": UNSUPPORTED_OPS[(kind, op)]})
173
                continue
174
            try:
175
                fx = self.fixture(kind, op, "crash")
176
                calib = calibrate(fx, self.bins[kind], self.work / "trials", self.instrument)
177
            except Exception as exc:  # noqa: BLE001
178
                for fam in families:
179
                    self.emit(kind, SCENARIO[fam], op, fam, {"classification": HARNESS_ERROR,
180
                                                             "harness_error": f"fixture/calibration: {exc}"})
181
                continue
182
            if not calib.ok:
183
                for fam in families:
184
                    self.emit(kind, SCENARIO[fam], op, fam, {"classification": HARNESS_ERROR,
185
                                                             "harness_error": "calibration: " + calib.detail})
186
                continue
187
            extra = self.transport(kind, "crash")
188
            if "crash_time" in families:
189
                fractions = TIME_FRACTIONS if not self.args.quick else (0.1, 0.5, 0.9)
190
                for frac in fractions:
191
                    row = run_time_point(fx, self.bins[kind], self.work / "trials", f"t-{kind}-{op}-{frac}", calib,
192
                                         frac, self.keep)
193
                    self.emit(kind, "crash_time_v1", op, "crash_time", {**row, **extra})
194
            if "crash_syscall" in families:
195
                reason = crashpoints.unavailable_reason(self.instrument, self.instrument_reason, kind)
196
                if reason:
197
                    self.emit(kind, "crash_syscall_v1", op, "crash_syscall", {"unsupported": reason})
198
                elif calib.boundaries == 0:
199
                    self.emit(kind, "crash_syscall_v1", op, "crash_syscall", {
200
                        "unsupported": crashpoints.NO_BOUNDARY_REASON[kind]})
201
                else:
202
                    points = syscall_points(calib.boundaries)
203
                    if self.args.quick:
204
                        points = points[:: max(1, len(points) // 4)][:4]
205
                    for n in points:
206
                        row = run_trial(fx, self.bins[kind], self.work / "trials", f"s-{kind}-{op}-{n}", calib,
207
                                        syscall_fault(self.instrument, n, calib.boundary_kinds), self.keep)
208
                        row.setdefault("observations", {})["boundaries_total"] = calib.boundaries
209
                        self.emit(kind, "crash_syscall_v1", op, "crash_syscall", {**row, **extra})
210
            shutil.rmtree(fx.root, ignore_errors=True)
211
212
    def proxy_family(self, kind: str) -> None:
213
        from robustness.proxycases import PER_REQUEST, fault_points, plan, record, run_proxy_trial
214
215
        for op in [op for op in self.args.ops if op in PROXY_OPS]:
216
            try:
217
                fx = self.fixture(kind, op, "proxy")
218
                calib, keys, methods = record(fx, self.bins[kind], self.work / "trials")
219
            except Exception as exc:  # noqa: BLE001
220
                self.emit(kind, "fault_proxy_v1", op, "proxy", {"classification": HARNESS_ERROR,
221
                                                                 "harness_error": f"record: {exc}"})
222
                continue
223
            if not calib.ok:
224
                self.emit(kind, "fault_proxy_v1", op, "proxy", {"classification": HARNESS_ERROR,
225
                                                                 "harness_error": "record: " + calib.detail})
226
                continue
227
            faults = plan(keys, methods)
228
            if self.args.quick:
229
                pts = fault_points(keys)
230
                keep_keys = {pts[0], pts[-1]} | {p for p in pts if p[0].startswith("POST")}
231
                faults = [f for f in faults if f.key is None or (f.key, f.occurrence) in keep_keys][:14]
232
            for index, fault in enumerate(faults):
233
                row = run_proxy_trial(fx, self.bins[kind], self.work / "trials", f"p-{kind}-{op}-{index}", calib,
234
                                      fault, self.keep)
235
                row.setdefault("observations", {})["recorded_requests"] = calib.requests[:60]
236
                self.emit(kind, "fault_proxy_v1", op, "proxy", {**row, **self.transport(kind, "proxy")})
237
            shutil.rmtree(fx.root, ignore_errors=True)
238
239
    def concurrency_family(self, kind: str) -> None:
240
        from robustness.concurrency import same_branch, shared_checkout
241
        from robustness.envfaults import FsyncLoad
242
243
        sizes = self.args.clients
244
        extra = self.transport(kind, "concurrency")
245
        for n in sizes:
246
            row = self._guard(lambda: same_branch(kind, self.bins[kind], self.work / f"conc-same-{kind}-{n}", n))
247
            self.emit(kind, "conc_same_branch_v1", "concurrent.push", "concurrency",
248
                      {**row, "fault_point": f"clients={n}", **extra})
249
            row = self._guard(lambda: same_branch(kind, self.bins[kind], self.work / f"conc-dist-{kind}-{n}", n,
250
                                                  distinct=True))
251
            self.emit(kind, "conc_distinct_branches_v1", "concurrent.push", "concurrency",
252
                      {**row, "fault_point": f"clients={n}", **extra})
253
        n = sizes[0]
254
        with FsyncLoad(self.work / f"fsyncload-{kind}"):
255
            row = self._guard(lambda: same_branch(kind, self.bins[kind], self.work / f"conc-load-{kind}", n))
256
        self.emit(kind, "env_fsync_load_v1", "concurrent.push", "env",
257
                  {**row, "fault_point": f"clients={n},fsync_writers=4", **extra})
258
        for what, scenario, op in (("commit", "lock_shared_commit_v1", "concurrent.commit"),
259
                                   ("push", "lock_shared_push_v1", "concurrent.push"),
260
                                   ("stale_owner", "lock_stale_owner_v1", "concurrent.commit")):
261
            row = self._guard(lambda: shared_checkout(kind, self.bins[kind], self.work / f"lock-{what}-{kind}", 8, what))
262
            self.emit(kind, scenario, op, "concurrency", {**row, "fault_point": "contenders=8",
263
                                                          **(extra if what == "push" else {})})
264
        self.emit(kind, "lock_credentials_v1", "credentials.save", "concurrency", {
265
            "unsupported": ("no non-interactive credential writer in the CLI (oak login is interactive; OAK_API_KEY "
266
                            "bypasses the store); credential-lock stress stays in oak unit tests (L8 credstress)")
267
            if kind == "oak" else "git has no credential store under test"})
268
269
    def env_family(self, kind: str) -> None:
270
        from robustness.envfaults import FREE_KIB, disk_full_trial, op_need_bytes, readonly_exercised, readonly_trial
271
272
        for op in [op for op in ("commit", "pull_ff", "clone") if op in self.args.ops]:
273
            try:
274
                fx = self.fixture(kind, op, "env")
275
                calib = calibrate(fx, self.bins[kind], self.work / "trials", None)
276
                need = op_need_bytes(fx, self.bins[kind], self.work / "trials")
277
            except Exception as exc:  # noqa: BLE001
278
                self.emit(kind, "env_disk_full_v1", op, "env", {"classification": HARNESS_ERROR,
279
                                                                 "harness_error": f"fixture: {exc}"})
280
                continue
281
            extra = self.transport(kind, "env")
282
            for free in (FREE_KIB if not self.args.quick else (0,)):
283
                row = disk_full_trial(fx, self.bins[kind], self.work / "trials", f"df-{kind}-{op}-{free}", calib, free,
284
                                      need)
285
                self.emit(kind, "env_disk_full_v1", op, "env", {**row, **extra})
286
            if op != "clone":
287
                for target in ("store", "subdir"):
288
                    if target == "subdir" and op == "commit":
289
                        continue
290
                    try:
291
                        exercised, ex_detail = readonly_exercised(fx, self.bins[kind], self.work / "trials",
292
                                                                  f"ro-probe-{kind}-{op}-{target}", target)
293
                    except Exception as exc:  # noqa: BLE001
294
                        exercised, ex_detail = None, {"probe_error": f"{type(exc).__name__}: {exc}"}
295
                    row = readonly_trial(fx, self.bins[kind], self.work / "trials", f"ro-{kind}-{op}-{target}", calib,
296
                                         target, exercised, ex_detail)
297
                    self.emit(kind, "env_readonly_v1", op, "env", {**row, **extra})
298
            shutil.rmtree(fx.root, ignore_errors=True)
299
300
    def hosted_family(self) -> None:
301
        from robustness.hosted import rows_spec
302
303
        for spec in rows_spec():
304
            self.emit("oak", "hosted_merge_invariants_v1", spec["operation"], "hosted",
305
                      {"unsupported": spec["skip_reason"], "fault_point": spec["invariant_id"]})
306
307
    @staticmethod
308
    def _guard(fn: Callable[[], dict]) -> dict:
309
        try:
310
            return fn()
311
        except Exception as exc:  # noqa: BLE001
312
            return {"classification": HARNESS_ERROR, "harness_error": f"{type(exc).__name__}: {exc}"}
313
314
    # main ------------------------------------------------------------------------
315
    def run(self) -> int:
316
        import subprocess
317
318
        self.versions = {}
319
        for kind in self.args.subjects:
320
            binary = self.bins.get(kind)
321
            if not binary or not Path(binary).exists():
322
                raise SystemExit(f"{kind} binary not found: {binary}")
323
            self.versions[kind] = subprocess.run([binary, "--version"], capture_output=True, text=True).stdout.strip()
324
        if "crash_syscall" in self.args.families:
325
            self.instrument, self.instrument_reason = crashpoints.build(
326
                self.work / "instrument", self.bins.get("oak") if "oak" in self.args.subjects else None,
327
                self.bins.get("git") if "git" in self.args.subjects else None)
328
        started = time.time()
329
        for kind in self.args.subjects:
330
            crash = [f for f in ("crash_time", "crash_syscall") if f in self.args.families]
331
            if crash:
332
                self.crash_family(kind, crash)
333
            if "proxy" in self.args.families:
334
                self.proxy_family(kind)
335
            if "concurrency" in self.args.families:
336
                self.concurrency_family(kind)
337
            if "env" in self.args.families:
338
                self.env_family(kind)
339
        if "hosted" in self.args.families:
340
            self.hosted_family()
341
        print()
342
        summary = summarize(self.rows, self.bench_id, time.time() - started)
343
        raw, summary_path = self.store.write(self.bench_id, self.rows, summary)
344
        print(summary)
345
        print(f"rows: {raw}\nsummary: {summary_path}")
346
        if not self.args.keep_workdir:
347
            shutil.rmtree(self.work / "trials", ignore_errors=True)
348
        return 1 if any(r["classification"] == VIOLATED for r in self.rows) and self.args.fail_on_violation else 0
349
350
351
def summarize(rows: list[dict], bench_id: str, seconds: float) -> str:
352
    table: dict[tuple, dict[str, int]] = {}
353
    for row in rows:
354
        key = (row["subject_kind"], row["scenario"], row["operation"])
355
        cell = table.setdefault(key, {c: 0 for c in CLASSIFICATIONS})
356
        cell[row["classification"]] += 1
357
    lines = [f"# Robustness suite {SUITE_VERSION} — {bench_id}", "",
358
             f"{len(rows)} cases in {seconds / 60:.1f} min.", "",
359
             "| subject | scenario | operation | held | violated | fault_not_fired | unsupported | harness_error |",
360
             "|---|---|---|---:|---:|---:|---:|---:|"]
361
    for (subject, scenario, operation), cell in sorted(table.items()):
362
        lines.append(f"| {subject} | {scenario} | {operation} | {cell[HELD]} | {cell[VIOLATED]} | "
363
                     f"{cell[FAULT_NOT_FIRED]} | {cell[UNSUPPORTED]} | {cell[HARNESS_ERROR]} |")
364
    totals = {c: sum(1 for r in rows if r["classification"] == c) for c in CLASSIFICATIONS}
365
    lines += ["", "Totals: " + ", ".join(f"{k}={v}" for k, v in totals.items()), ""]
366
    for subject in sorted({r["subject_kind"] for r in rows}):
367
        sub = {c: sum(1 for r in rows if r["subject_kind"] == subject and r["classification"] == c)
368
               for c in CLASSIFICATIONS}
369
        lines.append(f"- {subject}: " + ", ".join(f"{k}={v}" for k, v in sub.items()))
370
    lines.append("")
371
    violations: dict[tuple, list[str]] = {}
372
    for row in rows:
373
        if row["classification"] == VIOLATED:
374
            key = (row["subject_kind"], row["scenario"], row["operation"], ",".join(row["violations"]),
375
                   ",".join(row.get("known_bugs") or []))
376
            violations.setdefault(key, []).append(row["fault_point"])
377
    if violations:
378
        lines += ["## Violations", ""]
379
        for (subject, scenario, operation, names, bugs), points in sorted(violations.items()):
380
            tag = f" [{bugs}]" if bugs else ""
381
            lines.append(f"- {subject} {scenario} {operation}: {names}{tag} ({len(points)}x; e.g. "
382
                         f"{', '.join(points[:3])})")
383
    return "\n".join(lines) + "\n"
384
385
386
def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
387
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
388
    parser.add_argument("--subjects", default="oak,git")
389
    parser.add_argument("--families", default=",".join(FAMILIES))
390
    parser.add_argument("--ops", default=",".join(CRASH_OPS))
391
    parser.add_argument("--clients", default="8,16", help="concurrency sizes (comma-separated)")
392
    parser.add_argument("--oak-bin", default=shutil.which("oak"))
393
    parser.add_argument("--git-bin", default=None, help="default: real git (xcrun shim resolved)")
394
    parser.add_argument("--results", default=str(Path(__file__).resolve().parents[2] / "results" / "robustness"))
395
    parser.add_argument("--workdir", default=None)
396
    parser.add_argument("--bench-id", default=None)
397
    parser.add_argument("--quick", action="store_true", help="few points per case (smoke)")
398
    parser.add_argument("--max-evidence", type=int, default=40, help="violated/harness_error trial dirs retained")
399
    parser.add_argument("--keep-workdir", action="store_true")
400
    parser.add_argument("--fail-on-violation", action="store_true")
401
    parser.add_argument("--measurement-lock", action="store_true",
402
                        help="hold the benchmark measurement lock for the whole run (serialize with timing campaigns)")
403
    args = parser.parse_args(argv)
404
    args.subjects = [s for s in args.subjects.split(",") if s]
405
    args.families = [f for f in args.families.split(",") if f]
406
    args.ops = [o for o in args.ops.split(",") if o]
407
    args.clients = [int(c) for c in str(args.clients).split(",") if c]
408
    unknown = set(args.families) - set(FAMILIES)
409
    if unknown or not set(args.subjects) <= {"oak", "git"} or not set(args.ops) <= set(CRASH_OPS):
410
        parser.error(f"unknown family/subject/op: {sorted(unknown)} {args.subjects} {args.ops}")
411
    if args.git_bin is None:
412
        args.git_bin = which_measured_git()
413
    return args
414
415
416
def main(argv: Optional[list[str]] = None) -> int:
417
    args = parse_args(argv)
418
    if not args.measurement_lock:
419
        # Robustness cases are not timing measurements, and holding the shared
420
        # lock for a long run would block every other lane (and their tests).
421
        # Do not run the full suite beside a timing campaign; pass
422
        # --measurement-lock to serialize behind one instead.
423
        return Runner(args).run()
424
    from oakbench.runlock import measurement_lock
425
426
    with measurement_lock("robustness-suite"):
427
        return Runner(args).run()
428
429
430
if __name__ == "__main__":
431
    raise SystemExit(main())