Viewing files at commit e60fa60d3a02Commit detailsView latest

Browse files
suites/robustness/run_robustness.py 424 lines · 23.5 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_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
                    row = readonly_trial(fx, self.bins[kind], self.work / "trials", f"ro-{kind}-{op}-{target}", calib,
289
                                         target)
290
                    self.emit(kind, "env_readonly_v1", op, "env", {**row, **extra})
291
            shutil.rmtree(fx.root, ignore_errors=True)
292
293
    def hosted_family(self) -> None:
294
        from robustness.hosted import rows_spec
295
296
        for spec in rows_spec():
297
            self.emit("oak", "hosted_merge_invariants_v1", spec["operation"], "hosted",
298
                      {"unsupported": spec["skip_reason"], "fault_point": spec["invariant_id"]})
299
300
    @staticmethod
301
    def _guard(fn: Callable[[], dict]) -> dict:
302
        try:
303
            return fn()
304
        except Exception as exc:  # noqa: BLE001
305
            return {"classification": HARNESS_ERROR, "harness_error": f"{type(exc).__name__}: {exc}"}
306
307
    # main ------------------------------------------------------------------------
308
    def run(self) -> int:
309
        import subprocess
310
311
        self.versions = {}
312
        for kind in self.args.subjects:
313
            binary = self.bins.get(kind)
314
            if not binary or not Path(binary).exists():
315
                raise SystemExit(f"{kind} binary not found: {binary}")
316
            self.versions[kind] = subprocess.run([binary, "--version"], capture_output=True, text=True).stdout.strip()
317
        if "crash_syscall" in self.args.families:
318
            self.instrument, self.instrument_reason = crashpoints.build(
319
                self.work / "instrument", self.bins.get("oak") if "oak" in self.args.subjects else None,
320
                self.bins.get("git") if "git" in self.args.subjects else None)
321
        started = time.time()
322
        for kind in self.args.subjects:
323
            crash = [f for f in ("crash_time", "crash_syscall") if f in self.args.families]
324
            if crash:
325
                self.crash_family(kind, crash)
326
            if "proxy" in self.args.families:
327
                self.proxy_family(kind)
328
            if "concurrency" in self.args.families:
329
                self.concurrency_family(kind)
330
            if "env" in self.args.families:
331
                self.env_family(kind)
332
        if "hosted" in self.args.families:
333
            self.hosted_family()
334
        print()
335
        summary = summarize(self.rows, self.bench_id, time.time() - started)
336
        raw, summary_path = self.store.write(self.bench_id, self.rows, summary)
337
        print(summary)
338
        print(f"rows: {raw}\nsummary: {summary_path}")
339
        if not self.args.keep_workdir:
340
            shutil.rmtree(self.work / "trials", ignore_errors=True)
341
        return 1 if any(r["classification"] == VIOLATED for r in self.rows) and self.args.fail_on_violation else 0
342
343
344
def summarize(rows: list[dict], bench_id: str, seconds: float) -> str:
345
    table: dict[tuple, dict[str, int]] = {}
346
    for row in rows:
347
        key = (row["subject_kind"], row["scenario"], row["operation"])
348
        cell = table.setdefault(key, {c: 0 for c in CLASSIFICATIONS})
349
        cell[row["classification"]] += 1
350
    lines = [f"# Robustness suite {SUITE_VERSION} — {bench_id}", "",
351
             f"{len(rows)} cases in {seconds / 60:.1f} min.", "",
352
             "| subject | scenario | operation | held | violated | fault_not_fired | unsupported | harness_error |",
353
             "|---|---|---|---:|---:|---:|---:|---:|"]
354
    for (subject, scenario, operation), cell in sorted(table.items()):
355
        lines.append(f"| {subject} | {scenario} | {operation} | {cell[HELD]} | {cell[VIOLATED]} | "
356
                     f"{cell[FAULT_NOT_FIRED]} | {cell[UNSUPPORTED]} | {cell[HARNESS_ERROR]} |")
357
    totals = {c: sum(1 for r in rows if r["classification"] == c) for c in CLASSIFICATIONS}
358
    lines += ["", "Totals: " + ", ".join(f"{k}={v}" for k, v in totals.items()), ""]
359
    for subject in sorted({r["subject_kind"] for r in rows}):
360
        sub = {c: sum(1 for r in rows if r["subject_kind"] == subject and r["classification"] == c)
361
               for c in CLASSIFICATIONS}
362
        lines.append(f"- {subject}: " + ", ".join(f"{k}={v}" for k, v in sub.items()))
363
    lines.append("")
364
    violations: dict[tuple, list[str]] = {}
365
    for row in rows:
366
        if row["classification"] == VIOLATED:
367
            key = (row["subject_kind"], row["scenario"], row["operation"], ",".join(row["violations"]),
368
                   ",".join(row.get("known_bugs") or []))
369
            violations.setdefault(key, []).append(row["fault_point"])
370
    if violations:
371
        lines += ["## Violations", ""]
372
        for (subject, scenario, operation, names, bugs), points in sorted(violations.items()):
373
            tag = f" [{bugs}]" if bugs else ""
374
            lines.append(f"- {subject} {scenario} {operation}: {names}{tag} ({len(points)}x; e.g. "
375
                         f"{', '.join(points[:3])})")
376
    return "\n".join(lines) + "\n"
377
378
379
def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
380
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
381
    parser.add_argument("--subjects", default="oak,git")
382
    parser.add_argument("--families", default=",".join(FAMILIES))
383
    parser.add_argument("--ops", default=",".join(CRASH_OPS))
384
    parser.add_argument("--clients", default="8,16", help="concurrency sizes (comma-separated)")
385
    parser.add_argument("--oak-bin", default=shutil.which("oak"))
386
    parser.add_argument("--git-bin", default=None, help="default: real git (xcrun shim resolved)")
387
    parser.add_argument("--results", default=str(Path(__file__).resolve().parents[2] / "results" / "robustness"))
388
    parser.add_argument("--workdir", default=None)
389
    parser.add_argument("--bench-id", default=None)
390
    parser.add_argument("--quick", action="store_true", help="few points per case (smoke)")
391
    parser.add_argument("--max-evidence", type=int, default=40, help="violated/harness_error trial dirs retained")
392
    parser.add_argument("--keep-workdir", action="store_true")
393
    parser.add_argument("--fail-on-violation", action="store_true")
394
    parser.add_argument("--measurement-lock", action="store_true",
395
                        help="hold the benchmark measurement lock for the whole run (serialize with timing campaigns)")
396
    args = parser.parse_args(argv)
397
    args.subjects = [s for s in args.subjects.split(",") if s]
398
    args.families = [f for f in args.families.split(",") if f]
399
    args.ops = [o for o in args.ops.split(",") if o]
400
    args.clients = [int(c) for c in str(args.clients).split(",") if c]
401
    unknown = set(args.families) - set(FAMILIES)
402
    if unknown or not set(args.subjects) <= {"oak", "git"} or not set(args.ops) <= set(CRASH_OPS):
403
        parser.error(f"unknown family/subject/op: {sorted(unknown)} {args.subjects} {args.ops}")
404
    if args.git_bin is None:
405
        args.git_bin = which_measured_git()
406
    return args
407
408
409
def main(argv: Optional[list[str]] = None) -> int:
410
    args = parse_args(argv)
411
    if not args.measurement_lock:
412
        # Robustness cases are not timing measurements, and holding the shared
413
        # lock for a long run would block every other lane (and their tests).
414
        # Do not run the full suite beside a timing campaign; pass
415
        # --measurement-lock to serialize behind one instead.
416
        return Runner(args).run()
417
    from oakbench.runlock import measurement_lock
418
419
    with measurement_lock("robustness-suite"):
420
        return Runner(args).run()
421
422
423
if __name__ == "__main__":
424
    raise SystemExit(main())