Viewing files at commit f2acb751aa96Commit detailsView latest

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