Runners Log in

oak/benchmarks

suites/robustness/run_robustness.py 429 lines · 23.9 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("free_bytes_measured") is not None:
122
            row["free_bytes_measured"] = data["free_bytes_measured"]
123
        if data.get("harness_error"):
124
            row["harness_error"] = data["harness_error"]
125
        transport = data.get("remote_transport")
126
        if transport:
127
            row.update(remote_transport=transport, remote_server=data.get("remote_server"),
128
                       remote_repo=data.get("remote_repo"))
129
        if self.instrument is not None and family == "crash_syscall":
130
            row["crash_instrument"] = self.instrument.provenance
131
        self.rows.append(row)
132
        self.store.append_partial(self.bench_id, [row])
133
        mark = {HELD: ".", VIOLATED: "V", HARNESS_ERROR: "E", UNSUPPORTED: "u", FAULT_NOT_FIRED: "n"}[classification]
134
        print(mark, end="", flush=True)
135
        if classification in (VIOLATED, HARNESS_ERROR):
136
            print(f"\n  [{classification}] {subject_kind} {scenario} {operation} {row['fault_point']} "
137
                  f"{row['violations']} {row.get('harness_error', '')}", flush=True)
138
        return row
139
140
    def keep(self, trial, classification: str) -> None:
141
        trial.stop_server()
142
        if classification in (VIOLATED, HARNESS_ERROR) and self.retained < self.args.max_evidence:
143
            self.retained += 1
144
            dest = self.evidence / f"{self.retained:03d}-{trial.dir.name}"
145
            dest.parent.mkdir(parents=True, exist_ok=True)
146
            shutil.move(str(trial.dir), str(dest))
147
        else:
148
            shutil.rmtree(trial.dir, ignore_errors=True)
149
150
    def transport(self, kind: str, family: str) -> dict:
151
        if kind == "oak":
152
            return {"remote_transport": "network", "remote_server": "loopback-oak-serve", "remote_repo": "p4/robust"}
153
        if family == "proxy":
154
            return {"remote_transport": "network", "remote_server": "loopback-git-http-backend",
155
                    "remote_repo": "remote.git"}
156
        return {"remote_transport": "local_file", "remote_server": "local", "remote_repo": "../srv/remote.git"}
157
158
    # families --------------------------------------------------------------------
159
    def fixture(self, kind: str, op: str, tag: str):
160
        root = self.work / "fixtures" / f"{tag}-{kind}-{op}"
161
        if kind == "oak":
162
            return build_oak(self.bins["oak"], op, root, free_port())
163
        return build_git(self.bins["git"], op, root, free_port())
164
165
    def crash_family(self, kind: str, families: list[str]) -> None:
166
        ops = [op for op in self.args.ops if op in CRASH_OPS]
167
        for op in ops:
168
            if (kind, op) in UNSUPPORTED_OPS:
169
                for fam in families:
170
                    self.emit(kind, SCENARIO[fam], op, fam, {"unsupported": UNSUPPORTED_OPS[(kind, op)]})
171
                continue
172
            try:
173
                fx = self.fixture(kind, op, "crash")
174
                calib = calibrate(fx, self.bins[kind], self.work / "trials", self.instrument)
175
            except Exception as exc:  # noqa: BLE001
176
                for fam in families:
177
                    self.emit(kind, SCENARIO[fam], op, fam, {"classification": HARNESS_ERROR,
178
                                                             "harness_error": f"fixture/calibration: {exc}"})
179
                continue
180
            if not calib.ok:
181
                for fam in families:
182
                    self.emit(kind, SCENARIO[fam], op, fam, {"classification": HARNESS_ERROR,
183
                                                             "harness_error": "calibration: " + calib.detail})
184
                continue
185
            extra = self.transport(kind, "crash")
186
            if "crash_time" in families:
187
                fractions = TIME_FRACTIONS if not self.args.quick else (0.1, 0.5, 0.9)
188
                for frac in fractions:
189
                    row = run_time_point(fx, self.bins[kind], self.work / "trials", f"t-{kind}-{op}-{frac}", calib,
190
                                         frac, self.keep)
191
                    self.emit(kind, "crash_time_v1", op, "crash_time", {**row, **extra})
192
            if "crash_syscall" in families:
193
                reason = crashpoints.unavailable_reason(self.instrument, self.instrument_reason, kind)
194
                if reason:
195
                    self.emit(kind, "crash_syscall_v1", op, "crash_syscall", {"unsupported": reason})
196
                elif calib.boundaries == 0:
197
                    self.emit(kind, "crash_syscall_v1", op, "crash_syscall", {
198
                        "unsupported": crashpoints.NO_BOUNDARY_REASON[kind]})
199
                else:
200
                    points = syscall_points(calib.boundaries)
201
                    if self.args.quick:
202
                        points = points[:: max(1, len(points) // 4)][:4]
203
                    for n in points:
204
                        row = run_trial(fx, self.bins[kind], self.work / "trials", f"s-{kind}-{op}-{n}", calib,
205
                                        syscall_fault(self.instrument, n, calib.boundary_kinds), self.keep)
206
                        row.setdefault("observations", {})["boundaries_total"] = calib.boundaries
207
                        self.emit(kind, "crash_syscall_v1", op, "crash_syscall", {**row, **extra})
208
            shutil.rmtree(fx.root, ignore_errors=True)
209
210
    def proxy_family(self, kind: str) -> None:
211
        from robustness.proxycases import PER_REQUEST, fault_points, plan, record, run_proxy_trial
212
213
        for op in [op for op in self.args.ops if op in PROXY_OPS]:
214
            try:
215
                fx = self.fixture(kind, op, "proxy")
216
                calib, keys, methods = record(fx, self.bins[kind], self.work / "trials")
217
            except Exception as exc:  # noqa: BLE001
218
                self.emit(kind, "fault_proxy_v1", op, "proxy", {"classification": HARNESS_ERROR,
219
                                                                 "harness_error": f"record: {exc}"})
220
                continue
221
            if not calib.ok:
222
                self.emit(kind, "fault_proxy_v1", op, "proxy", {"classification": HARNESS_ERROR,
223
                                                                 "harness_error": "record: " + calib.detail})
224
                continue
225
            faults = plan(keys, methods)
226
            if self.args.quick:
227
                pts = fault_points(keys)
228
                keep_keys = {pts[0], pts[-1]} | {p for p in pts if p[0].startswith("POST")}
229
                faults = [f for f in faults if f.key is None or (f.key, f.occurrence) in keep_keys][:14]
230
            for index, fault in enumerate(faults):
231
                row = run_proxy_trial(fx, self.bins[kind], self.work / "trials", f"p-{kind}-{op}-{index}", calib,
232
                                      fault, self.keep)
233
                row.setdefault("observations", {})["recorded_requests"] = calib.requests[:60]
234
                self.emit(kind, "fault_proxy_v1", op, "proxy", {**row, **self.transport(kind, "proxy")})
235
            shutil.rmtree(fx.root, ignore_errors=True)
236
237
    def concurrency_family(self, kind: str) -> None:
238
        from robustness.concurrency import same_branch, shared_checkout
239
        from robustness.envfaults import FsyncLoad
240
241
        sizes = self.args.clients
242
        extra = self.transport(kind, "concurrency")
243
        for n in sizes:
244
            row = self._guard(lambda: same_branch(kind, self.bins[kind], self.work / f"conc-same-{kind}-{n}", n))
245
            self.emit(kind, "conc_same_branch_v1", "concurrent.push", "concurrency",
246
                      {**row, "fault_point": f"clients={n}", **extra})
247
            row = self._guard(lambda: same_branch(kind, self.bins[kind], self.work / f"conc-dist-{kind}-{n}", n,
248
                                                  distinct=True))
249
            self.emit(kind, "conc_distinct_branches_v1", "concurrent.push", "concurrency",
250
                      {**row, "fault_point": f"clients={n}", **extra})
251
        n = sizes[0]
252
        with FsyncLoad(self.work / f"fsyncload-{kind}"):
253
            row = self._guard(lambda: same_branch(kind, self.bins[kind], self.work / f"conc-load-{kind}", n))
254
        self.emit(kind, "env_fsync_load_v1", "concurrent.push", "env",
255
                  {**row, "fault_point": f"clients={n},fsync_writers=4", **extra})
256
        for what, scenario, op in (("commit", "lock_shared_commit_v1", "concurrent.commit"),
257
                                   ("push", "lock_shared_push_v1", "concurrent.push"),
258
                                   ("stale_owner", "lock_stale_owner_v1", "concurrent.commit")):
259
            row = self._guard(lambda: shared_checkout(kind, self.bins[kind], self.work / f"lock-{what}-{kind}", 8, what))
260
            self.emit(kind, scenario, op, "concurrency", {**row, "fault_point": "contenders=8",
261
                                                          **(extra if what == "push" else {})})
262
        self.emit(kind, "lock_credentials_v1", "credentials.save", "concurrency", {
263
            "unsupported": ("no non-interactive credential writer in the CLI (oak login is interactive; OAK_API_KEY "
264
                            "bypasses the store); credential-lock stress stays in oak unit tests (L8 credstress)")
265
            if kind == "oak" else "git has no credential store under test"})
266
267
    def env_family(self, kind: str) -> None:
268
        from robustness.envfaults import FREE_KIB, disk_full_trial, op_need_bytes, readonly_exercised, readonly_trial
269
270
        for op in [op for op in ("commit", "pull_ff", "clone") if op in self.args.ops]:
271
            try:
272
                fx = self.fixture(kind, op, "env")
273
                calib = calibrate(fx, self.bins[kind], self.work / "trials", None)
274
                need = op_need_bytes(fx, self.bins[kind], self.work / "trials")
275
            except Exception as exc:  # noqa: BLE001
276
                self.emit(kind, "env_disk_full_v1", op, "env", {"classification": HARNESS_ERROR,
277
                                                                 "harness_error": f"fixture: {exc}"})
278
                continue
279
            extra = self.transport(kind, "env")
280
            for free in (FREE_KIB if not self.args.quick else (0,)):
281
                row = disk_full_trial(fx, self.bins[kind], self.work / "trials", f"df-{kind}-{op}-{free}", calib, free,
282
                                      need)
283
                self.emit(kind, "env_disk_full_v1", op, "env", {**row, **extra})
284
            if op != "clone":
285
                for target in ("store", "subdir"):
286
                    if target == "subdir" and op == "commit":
287
                        continue
288
                    try:
289
                        exercised, ex_detail = readonly_exercised(fx, self.bins[kind], self.work / "trials",
290
                                                                  f"ro-probe-{kind}-{op}-{target}", target)
291
                    except Exception as exc:  # noqa: BLE001
292
                        exercised, ex_detail = None, {"probe_error": f"{type(exc).__name__}: {exc}"}
293
                    row = readonly_trial(fx, self.bins[kind], self.work / "trials", f"ro-{kind}-{op}-{target}", calib,
294
                                         target, exercised, ex_detail)
295
                    self.emit(kind, "env_readonly_v1", op, "env", {**row, **extra})
296
            shutil.rmtree(fx.root, ignore_errors=True)
297
298
    def hosted_family(self) -> None:
299
        from robustness.hosted import rows_spec
300
301
        for spec in rows_spec():
302
            self.emit("oak", "hosted_merge_invariants_v1", spec["operation"], "hosted",
303
                      {"unsupported": spec["skip_reason"], "fault_point": spec["invariant_id"]})
304
305
    @staticmethod
306
    def _guard(fn: Callable[[], dict]) -> dict:
307
        try:
308
            return fn()
309
        except Exception as exc:  # noqa: BLE001
310
            return {"classification": HARNESS_ERROR, "harness_error": f"{type(exc).__name__}: {exc}"}
311
312
    # main ------------------------------------------------------------------------
313
    def run(self) -> int:
314
        import subprocess
315
316
        self.versions = {}
317
        for kind in self.args.subjects:
318
            binary = self.bins.get(kind)
319
            if not binary or not Path(binary).exists():
320
                raise SystemExit(f"{kind} binary not found: {binary}")
321
            self.versions[kind] = subprocess.run([binary, "--version"], capture_output=True, text=True).stdout.strip()
322
        if "crash_syscall" in self.args.families:
323
            self.instrument, self.instrument_reason = crashpoints.build(
324
                self.work / "instrument", self.bins.get("oak") if "oak" in self.args.subjects else None,
325
                self.bins.get("git") if "git" in self.args.subjects else None)
326
        started = time.time()
327
        for kind in self.args.subjects:
328
            crash = [f for f in ("crash_time", "crash_syscall") if f in self.args.families]
329
            if crash:
330
                self.crash_family(kind, crash)
331
            if "proxy" in self.args.families:
332
                self.proxy_family(kind)
333
            if "concurrency" in self.args.families:
334
                self.concurrency_family(kind)
335
            if "env" in self.args.families:
336
                self.env_family(kind)
337
        if "hosted" in self.args.families:
338
            self.hosted_family()
339
        print()
340
        summary = summarize(self.rows, self.bench_id, time.time() - started)
341
        raw, summary_path = self.store.write(self.bench_id, self.rows, summary)
342
        print(summary)
343
        print(f"rows: {raw}\nsummary: {summary_path}")
344
        if not self.args.keep_workdir:
345
            shutil.rmtree(self.work / "trials", ignore_errors=True)
346
        return 1 if any(r["classification"] == VIOLATED for r in self.rows) and self.args.fail_on_violation else 0
347
348
349
def summarize(rows: list[dict], bench_id: str, seconds: float) -> str:
350
    table: dict[tuple, dict[str, int]] = {}
351
    for row in rows:
352
        key = (row["subject_kind"], row["scenario"], row["operation"])
353
        cell = table.setdefault(key, {c: 0 for c in CLASSIFICATIONS})
354
        cell[row["classification"]] += 1
355
    lines = [f"# Robustness suite {SUITE_VERSION} — {bench_id}", "",
356
             f"{len(rows)} cases in {seconds / 60:.1f} min.", "",
357
             "| subject | scenario | operation | held | violated | fault_not_fired | unsupported | harness_error |",
358
             "|---|---|---|---:|---:|---:|---:|---:|"]
359
    for (subject, scenario, operation), cell in sorted(table.items()):
360
        lines.append(f"| {subject} | {scenario} | {operation} | {cell[HELD]} | {cell[VIOLATED]} | "
361
                     f"{cell[FAULT_NOT_FIRED]} | {cell[UNSUPPORTED]} | {cell[HARNESS_ERROR]} |")
362
    totals = {c: sum(1 for r in rows if r["classification"] == c) for c in CLASSIFICATIONS}
363
    lines += ["", "Totals: " + ", ".join(f"{k}={v}" for k, v in totals.items()), ""]
364
    for subject in sorted({r["subject_kind"] for r in rows}):
365
        sub = {c: sum(1 for r in rows if r["subject_kind"] == subject and r["classification"] == c)
366
               for c in CLASSIFICATIONS}
367
        lines.append(f"- {subject}: " + ", ".join(f"{k}={v}" for k, v in sub.items()))
368
    lines.append("")
369
    violations: dict[tuple, list[str]] = {}
370
    for row in rows:
371
        if row["classification"] == VIOLATED:
372
            key = (row["subject_kind"], row["scenario"], row["operation"], ",".join(row["violations"]),
373
                   ",".join(row.get("known_bugs") or []))
374
            violations.setdefault(key, []).append(row["fault_point"])
375
    if violations:
376
        lines += ["## Violations", ""]
377
        for (subject, scenario, operation, names, bugs), points in sorted(violations.items()):
378
            tag = f" [{bugs}]" if bugs else ""
379
            lines.append(f"- {subject} {scenario} {operation}: {names}{tag} ({len(points)}x; e.g. "
380
                         f"{', '.join(points[:3])})")
381
    return "\n".join(lines) + "\n"
382
383
384
def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
385
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
386
    parser.add_argument("--subjects", default="oak,git")
387
    parser.add_argument("--families", default=",".join(FAMILIES))
388
    parser.add_argument("--ops", default=",".join(CRASH_OPS))
389
    parser.add_argument("--clients", default="8,16", help="concurrency sizes (comma-separated)")
390
    parser.add_argument("--oak-bin", default=shutil.which("oak"))
391
    parser.add_argument("--git-bin", default=None, help="default: real git (xcrun shim resolved)")
392
    parser.add_argument("--results", default=str(Path(__file__).resolve().parents[2] / "results" / "robustness"))
393
    parser.add_argument("--workdir", default=None)
394
    parser.add_argument("--bench-id", default=None)
395
    parser.add_argument("--quick", action="store_true", help="few points per case (smoke)")
396
    parser.add_argument("--max-evidence", type=int, default=40, help="violated/harness_error trial dirs retained")
397
    parser.add_argument("--keep-workdir", action="store_true")
398
    parser.add_argument("--fail-on-violation", action="store_true")
399
    parser.add_argument("--measurement-lock", action="store_true",
400
                        help="hold the benchmark measurement lock for the whole run (serialize with timing campaigns)")
401
    args = parser.parse_args(argv)
402
    args.subjects = [s for s in args.subjects.split(",") if s]
403
    args.families = [f for f in args.families.split(",") if f]
404
    args.ops = [o for o in args.ops.split(",") if o]
405
    args.clients = [int(c) for c in str(args.clients).split(",") if c]
406
    unknown = set(args.families) - set(FAMILIES)
407
    if unknown or not set(args.subjects) <= {"oak", "git"} or not set(args.ops) <= set(CRASH_OPS):
408
        parser.error(f"unknown family/subject/op: {sorted(unknown)} {args.subjects} {args.ops}")
409
    if args.git_bin is None:
410
        args.git_bin = which_measured_git()
411
    return args
412
413
414
def main(argv: Optional[list[str]] = None) -> int:
415
    args = parse_args(argv)
416
    if not args.measurement_lock:
417
        # Robustness cases are not timing measurements, and holding the shared
418
        # lock for a long run would block every other lane (and their tests).
419
        # Do not run the full suite beside a timing campaign; pass
420
        # --measurement-lock to serialize behind one instead.
421
        return Runner(args).run()
422
    from oakbench.runlock import measurement_lock
423
424
    with measurement_lock("robustness-suite"):
425
        return Runner(args).run()
426
427
428
if __name__ == "__main__":
429
    raise SystemExit(main())