Log in
scripts/netshape_bench.py 869 lines · 35.2 KB · python Blame
1
#!/usr/bin/env python3
2
"""netshape lane: git vs oak network operations through the same shaped pipe.
3
4
Both subjects are served from local disk through one Linux netns + veth +
5
tc/netem pipe (profiles from config/netshapes.json), which is what makes a
6
cross-subject network comparison legitimate. Before any campaign rows, a
7
self-test row asserts the pipe's achieved RTT is within +/-15% of the
8
configured profile β€” the pipe is an instrument, and it is tested like one.
9
10
Skip-row honesty (ADR-0002):
11
- On hosts that cannot shape packets (macOS, missing ip/tc, no
12
  CAP_NET_ADMIN), every requested run emits a returncode-77 skip row with
13
  reason "netshape_unavailable:<reason>" and the process exits 3. Never an
14
  error: an unshaped host is a coverage gap, not a crash.
15
- The oak subject needs OAK_BENCH_LOCAL_SERVER_CMD (a self-hostable oak
16
  server is a flagged product dependency). Absent, oak runs emit skip rows
17
  with reason "oak_local_server_missing". The skip is the pressure: the row
18
  stream itself records that oak cannot yet be benchmarked on a local shaped
19
  pipe.
20
21
Transport honesty: every row carries remote_transport "network_shaped" plus
22
netshape_profile; shaped rows are comparable only same-profile/same-server
23
(oakbench.remotes.shaped_comparison_legal).
24
25
Bytes on wire: the gitserver/oak server sit behind oakbench.byteproxy's
26
CountingProxy bound to the shaped pipe's host address, so measured rows carry
27
wire byte counts. Null means unmeasured.
28
"""
29
30
from __future__ import annotations
31
32
import argparse
33
import json
34
import os
35
import platform as platform_module
36
import shlex
37
import shutil
38
import socket
39
import subprocess
40
import sys
41
import tempfile
42
import time
43
from datetime import datetime, timezone
44
from pathlib import Path
45
from typing import Any, Callable, Optional
46
47
from oakbench import netshape as oakbench_netshape
48
from oakbench import remotes as oakbench_remotes
49
from oakbench.byteproxy import CountingProxy
50
from oakbench.environment import ENV_ISOLATION_VERSION, base_env as oakbench_base_env
51
from oakbench.execution import run_timed
52
from oakbench.gitserver import GitHTTPServer
53
from oakbench.rows import RowContractViolation, validate_rows
54
55
ROOT = Path(__file__).resolve().parents[1]
56
57
EXIT_OK = 0
58
EXIT_FAILURES = 1
59
EXIT_USAGE = 2
60
EXIT_ALL_SKIPPED = 3
61
SKIP_RETURNCODE = 77
62
63
SCENARIOS = (
64
    "netshape_clone_cold",
65
    "netshape_pull_uptodate",
66
    "netshape_push_small_delta",
67
    "netshape_fetch_negotiation_refs100k",
68
    # _cold is part of the identity (ADR-0005): the measured unit is a cold
69
    # network acquire plus the first file-history query, like
70
    # netshape_clone_cold encodes its cache state in the name.
71
    "netshape_log_follow_cold",
72
    "netshape_mount_cold",
73
    "netshape_task_loop",
74
)
75
SUBJECT_KINDS = {"git": "git", "oak": "oak"}
76
SERVER_NAME = "netshape-local"
77
OAK_LOCAL_SERVER_ENV = "OAK_BENCH_LOCAL_SERVER_CMD"
78
OAK_SERVED_REPO = "bench"
79
TASK_LOOP_ITERATIONS = 3
80
81
EPILOG = f"""\
82
oak subject: a self-hostable oak server is a flagged product dependency. Set
83
{OAK_LOCAL_SERVER_ENV} to an argv command template ({{host}} {{port}} {{root}}
84
placeholders) that serves a repo named '{OAK_SERVED_REPO}' from {{root}} at
85
http://{{host}}:{{port}}. When it is absent, oak runs emit skip rows with
86
reason "oak_local_server_missing" β€” the skip is the pressure: the row stream
87
records that oak cannot yet be benchmarked on a local shaped pipe.
88
89
This lane needs Linux + ip/tc + CAP_NET_ADMIN (run as root). Anywhere else it
90
emits skip rows (reason "netshape_unavailable:<reason>") and exits 3.
91
"""
92
93
94
def base_env() -> dict[str, str]:
95
    return oakbench_base_env(
96
        author_name="Oak Netshape",
97
        author_email="[email protected]",
98
        oak_author="oak-netshape",
99
    )
100
101
102
def local_server_argv(template: str, *, host: str, port: int, root: Path) -> list[str]:
103
    rendered = template.format(host=host, port=port, root=str(root))
104
    argv = shlex.split(rendered)
105
    if not argv:
106
        raise ValueError(f"{OAK_LOCAL_SERVER_ENV} rendered to an empty command")
107
    return argv
108
109
110
def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
111
    parser = argparse.ArgumentParser(
112
        description=__doc__,
113
        epilog=EPILOG,
114
        formatter_class=argparse.RawDescriptionHelpFormatter,
115
    )
116
    parser.add_argument("--profile", default="lan-1ms", help="netshape profile id from config/netshapes.json")
117
    parser.add_argument("--scenario", choices=SCENARIOS, default="netshape_clone_cold")
118
    parser.add_argument("--subjects", default="git,oak", help="comma-separated: git,oak")
119
    parser.add_argument("--results", type=Path, default=ROOT / "results" / "netshape",
120
                        help="results directory, or an explicit .jsonl file path")
121
    parser.add_argument("--runs", type=int, default=3)
122
    parser.add_argument("--netshapes", type=Path, default=oakbench_netshape.DEFAULT_NETSHAPES)
123
    parser.add_argument("--refs", type=int, default=100_000,
124
                        help="ref count seeded for the fetch-negotiation scenario")
125
    parser.add_argument("--git-bin", default="git")
126
    parser.add_argument("--oak-bin", default="oak")
127
    parser.add_argument("--admitted-output-chars", type=int, default=20_000)
128
    return parser.parse_args(argv)
129
130
131
def metadata(bench_id: str, args: argparse.Namespace) -> dict[str, Any]:
132
    return {
133
        "bench_id": bench_id,
134
        "profile": "netshape",
135
        "timestamp_utc": bench_id,
136
        "host": platform_module.node(),
137
        "platform": platform_module.platform(),
138
        "machine": platform_module.machine(),
139
        "python": platform_module.python_version(),
140
        "env_isolation_version": ENV_ISOLATION_VERSION,
141
        "netshape_profile": args.profile,
142
        "remote_transport": oakbench_remotes.TRANSPORT_NETWORK_SHAPED,
143
        "remote_server": SERVER_NAME,
144
    }
145
146
147
def skip_row(
148
    meta: dict[str, Any],
149
    scenario: str,
150
    subject: str,
151
    run_index: int,
152
    reason: str,
153
    operation: str = "scenario.skip",
154
) -> dict[str, Any]:
155
    return {
156
        **meta,
157
        "scenario": scenario,
158
        "operation": operation,
159
        "run": run_index,
160
        "subject": subject,
161
        "subject_kind": SUBJECT_KINDS.get(subject, subject),
162
        "elapsed_ms": 0.0,
163
        "returncode": SKIP_RETURNCODE,
164
        "command": [],
165
        "skipped": True,
166
        "skip_reason": reason,
167
    }
168
169
170
def write_rows(results_arg: Path, bench_id: str, rows: list[dict[str, Any]]) -> Path:
171
    """Write JSONL, then validate against the shared row contract.
172
173
    rows.py owns no 'netshape' lane yet and this slice may not modify it, so
174
    netshape rows opt into the core key contract (a strict superset of what
175
    they carry) to keep skip-row coherence machine-checked.
176
    """
177
    if results_arg.suffix == ".jsonl":
178
        path = results_arg
179
    else:
180
        path = results_arg / f"netshape-{bench_id}.jsonl"
181
    path.parent.mkdir(parents=True, exist_ok=True)
182
    with path.open("w") as fh:
183
        for row in rows:
184
            fh.write(json.dumps(row, sort_keys=True) + "\n")
185
    errors = validate_rows(rows, "core")
186
    if errors:
187
        preview = "\n  ".join(errors[:20])
188
        raise RowContractViolation(
189
            f"Row contract violation (netshape lane). Rows were written to {path} "
190
            f"but the emitter has a bug:\n  {preview}"
191
        )
192
    return path
193
194
195
# --------------------------------------------------------------------------
196
# Linux-with-privileges measurement path. Untestable on this macOS dev host
197
# by design (skip-row honesty above); kept simple and obviously correct.
198
# --------------------------------------------------------------------------
199
200
201
def run_untimed(command: list[str], cwd: Optional[Path] = None, timeout: float = 600.0) -> subprocess.CompletedProcess:
202
    return subprocess.run(
203
        command,
204
        cwd=cwd,
205
        env=base_env(),
206
        stdout=subprocess.PIPE,
207
        stderr=subprocess.PIPE,
208
        text=True,
209
        timeout=timeout,
210
        check=False,
211
    )
212
213
214
def timed_row(
215
    meta: dict[str, Any],
216
    args: argparse.Namespace,
217
    subject: str,
218
    run_index: int,
219
    operation: str,
220
    command: list[str],
221
    cwd: Path,
222
    proxy: Optional[CountingProxy],
223
    extra: Optional[dict[str, Any]] = None,
224
) -> dict[str, Any]:
225
    if proxy is not None:
226
        proxy.reset_counts()
227
    capture = run_timed(command, cwd, base_env(), args.admitted_output_chars)
228
    row: dict[str, Any] = {
229
        **meta,
230
        "scenario": args.scenario,
231
        "operation": operation,
232
        "run": run_index,
233
        "subject": subject,
234
        "subject_kind": SUBJECT_KINDS.get(subject, subject),
235
        "elapsed_ms": round(capture.elapsed_ms, 3),
236
        "returncode": capture.returncode,
237
        "command": command,
238
        "stdout_bytes": capture.stdout_bytes,
239
        "stderr_bytes": capture.stderr_bytes,
240
        # Null means unmeasured (ADR-0002): byte counts and first-payload
241
        # timing exist only when the operation went through the counting
242
        # proxy, and the keys are always present so a missing measurement is
243
        # an explicit null, never an absent key.
244
        "wire_bytes_client_to_server": None,
245
        "wire_bytes_server_to_client": None,
246
        "wire_bytes_total": None,
247
        "wire_connections": None,
248
        "ttfd_ms": None,
249
        "ttfd_source": None,
250
        "first_payload_bytes_server_to_client": None,
251
    }
252
    if capture.returncode != 0 and capture.stderr_text:
253
        row["stderr"] = capture.stderr_text[-4000:]
254
    if proxy is not None:
255
        stats = proxy.snapshot()
256
        row.update(
257
            {
258
                "wire_bytes_client_to_server": stats.client_to_upstream_bytes,
259
                "wire_bytes_server_to_client": stats.upstream_to_client_bytes,
260
                "wire_bytes_total": stats.total_bytes,
261
                "wire_connections": stats.connections,
262
                "ttfd_ms": stats.first_upstream_to_client_ms,
263
                "ttfd_source": (
264
                    "byteproxy_first_upstream_to_client_byte"
265
                    if stats.first_upstream_to_client_ms is not None
266
                    else None
267
                ),
268
                "first_payload_bytes_server_to_client": stats.first_payload_bytes_server_to_client,
269
            }
270
        )
271
    if extra:
272
        row.update(extra)
273
    return row
274
275
276
def selftest_row(meta: dict[str, Any], args: argparse.Namespace, selftest: dict[str, Any]) -> dict[str, Any]:
277
    return {
278
        **meta,
279
        **selftest,
280
        "scenario": args.scenario,
281
        "run": 0,
282
        "subject": "harness",
283
        "subject_kind": "harness",
284
        "elapsed_ms": float(selftest["measured_rtt_ms"]),
285
        "returncode": 0 if selftest["within_tolerance"] else 1,
286
        "command": ["netshape:selftest", args.profile],
287
    }
288
289
290
def make_served_git_repo(
291
    git_bin: str,
292
    workdir: Path,
293
    served_root: Path,
294
    refs_count: int,
295
    history_commits: int = 1,
296
) -> Path:
297
    """Untimed precondition: a small source repo, served as a bare repo with
298
    http.receivepack enabled; optionally seeded with many refs."""
299
    src = workdir / "git-src"
300
    run_untimed([git_bin, "-c", "init.defaultBranch=main", "init", str(src)])
301
    (src / "README.md").write_text("# netshape fixture\n")
302
    (src / "src").mkdir(exist_ok=True)
303
    (src / "src" / "main.py").write_text("print('netshape')\n")
304
    run_untimed([git_bin, "add", "-A"], cwd=src)
305
    run_untimed([git_bin, "commit", "-m", "netshape fixture"], cwd=src)
306
    for index in range(1, max(1, history_commits)):
307
        with (src / "src" / "main.py").open("a") as fh:
308
            fh.write(f"print('netshape history {index}')\n")
309
        run_untimed([git_bin, "add", "src/main.py"], cwd=src)
310
        run_untimed([git_bin, "commit", "-m", f"history {index}"], cwd=src)
311
    bare = served_root / "repo.git"
312
    run_untimed([git_bin, "clone", "--bare", str(src), str(bare)])
313
    run_untimed([git_bin, "-C", str(bare), "config", "http.receivepack", "true"])
314
    if refs_count > 0:
315
        head = run_untimed([git_bin, "-C", str(bare), "rev-parse", "HEAD"]).stdout.strip()
316
        lines = "".join(f"create refs/bench/r{i} {head}\n" for i in range(refs_count))
317
        subprocess.run(
318
            [git_bin, "-C", str(bare), "update-ref", "--stdin"],
319
            input=lines,
320
            env=base_env(),
321
            text=True,
322
            stdout=subprocess.DEVNULL,
323
            stderr=subprocess.DEVNULL,
324
            check=False,
325
        )
326
    return bare
327
328
329
def timed_sequence_row(
330
    meta: dict[str, Any],
331
    args: argparse.Namespace,
332
    subject: str,
333
    run_index: int,
334
    operation: str,
335
    commands: list[list[str]],
336
    cwd: Path,
337
    proxy: Optional[CountingProxy],
338
    extra: Optional[dict[str, Any]] = None,
339
) -> dict[str, Any]:
340
    """Time a small command sequence as one semantic operation.
341
342
    Used when the measured unit is "cold network acquire until X is available";
343
    splitting the clone into an untimed precondition would hide the network bytes
344
    that make the operation meaningful.
345
    """
346
    if proxy is not None:
347
        proxy.reset_counts()
348
    stdout_bytes = 0
349
    stderr_bytes = 0
350
    stderr_tail = ""
351
    returncode = 0
352
    start = time.perf_counter()
353
    for command in commands:
354
        proc = subprocess.run(
355
            command,
356
            cwd=cwd,
357
            env=base_env(),
358
            stdout=subprocess.PIPE,
359
            stderr=subprocess.PIPE,
360
            timeout=1800.0,
361
            check=False,
362
        )
363
        stdout_bytes += len(proc.stdout or b"")
364
        stderr = proc.stderr or b""
365
        stderr_bytes += len(stderr)
366
        if stderr:
367
            stderr_tail = (stderr_tail + stderr.decode("utf-8", errors="replace"))[-4000:]
368
        if proc.returncode != 0:
369
            returncode = proc.returncode
370
            break
371
    elapsed_ms = (time.perf_counter() - start) * 1000
372
    row: dict[str, Any] = {
373
        **meta,
374
        "scenario": args.scenario,
375
        "operation": operation,
376
        "run": run_index,
377
        "subject": subject,
378
        "subject_kind": SUBJECT_KINDS.get(subject, subject),
379
        "elapsed_ms": round(elapsed_ms, 3),
380
        "returncode": returncode,
381
        "command": commands,
382
        "stdout_bytes": stdout_bytes,
383
        "stderr_bytes": stderr_bytes,
384
        # Null means unmeasured (ADR-0002): the ttfd/first-payload keys are
385
        # always present; null records that the proxy never observed an
386
        # upstream-to-client payload byte, never a zero.
387
        "wire_bytes_client_to_server": None,
388
        "wire_bytes_server_to_client": None,
389
        "wire_bytes_total": None,
390
        "wire_connections": None,
391
        "ttfd_ms": None,
392
        "ttfd_source": None,
393
        "first_payload_bytes_server_to_client": None,
394
    }
395
    if stderr_tail:
396
        row["stderr"] = stderr_tail
397
    if proxy is not None:
398
        stats = proxy.snapshot()
399
        row.update(
400
            {
401
                "wire_bytes_client_to_server": stats.client_to_upstream_bytes,
402
                "wire_bytes_server_to_client": stats.upstream_to_client_bytes,
403
                "wire_bytes_total": stats.total_bytes,
404
                "wire_connections": stats.connections,
405
                "ttfd_ms": stats.first_upstream_to_client_ms,
406
                "ttfd_source": (
407
                    "byteproxy_first_upstream_to_client_byte"
408
                    if stats.first_upstream_to_client_ms is not None
409
                    else None
410
                ),
411
                "first_payload_bytes_server_to_client": stats.first_payload_bytes_server_to_client,
412
            }
413
        )
414
    if extra:
415
        row.update(extra)
416
    return row
417
418
419
def free_port() -> int:
420
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
421
        sock.bind(("127.0.0.1", 0))
422
        return int(sock.getsockname()[1])
423
424
425
def wait_for_port(host: str, port: int, timeout_s: float = 30.0) -> bool:
426
    deadline = time.monotonic() + timeout_s
427
    while time.monotonic() < deadline:
428
        try:
429
            with socket.create_connection((host, port), timeout=1.0):
430
                return True
431
        except OSError:
432
            time.sleep(0.2)
433
    return False
434
435
436
def seed_oak_served_repo(
437
    oak_bin: str,
438
    server_url: str,
439
    workdir: Path,
440
    runner: Callable[..., subprocess.CompletedProcess] = run_untimed,
441
    history_commits: int = 1,
442
) -> tuple[bool, str]:
443
    """Untimed precondition: OAK_BENCH_LOCAL_SERVER_CMD serves a freshly
444
    created EMPTY root, so the served repo must be seeded before any measured
445
    clone/pull/push has something to talk to (the task_loop lane's
446
    ensure_git_remote_seeded idiom, oak-shaped: init a tiny workdir, commit
447
    one file, push it to the server URL).
448
449
    Returns (ok, reason). On failure the reason names the failed stage β€”
450
    "oak_server_seed_failed:<stage>" β€” and the caller must emit per-run skip
451
    rows instead of measured rows (ADR-0002: an unseedable server is a
452
    coverage gap, never fabricated measurements). ``runner`` is injectable so
453
    the helper is testable on hosts that cannot run the Linux path.
454
    """
455
    seed = workdir / "oak-seed-src"
456
    seed.mkdir(parents=True, exist_ok=True)
457
    (seed / "README.md").write_text("# netshape oak fixture (server seed)\n")
458
    (seed / "src").mkdir(exist_ok=True)
459
    (seed / "src" / "main.py").write_text("print('netshape oak')\n")
460
    stages = (
461
        ("init", [oak_bin, "init", "."]),
462
        ("commit", [oak_bin, "commit", "--no-verify"]),
463
        ("push", [oak_bin, "push", server_url]),
464
    )
465
    for stage, command in stages:
466
        proc = runner(command, cwd=seed)
467
        if proc.returncode != 0:
468
            return False, f"oak_server_seed_failed:{stage}"
469
    for index in range(1, max(1, history_commits)):
470
        with (seed / "src" / "main.py").open("a") as fh:
471
            fh.write(f"print('netshape oak history {index}')\n")
472
        proc = runner([oak_bin, "commit", "--no-verify"], cwd=seed)
473
        if proc.returncode != 0:
474
            return False, "oak_server_seed_failed:history_commit"
475
        proc = runner([oak_bin, "push", server_url], cwd=seed)
476
        if proc.returncode != 0:
477
            return False, "oak_server_seed_failed:history_push"
478
    return True, ""
479
480
481
def precondition_clone_failed_rows(
482
    meta: dict[str, Any],
483
    scenario: str,
484
    subject: str,
485
    run_index: int,
486
    command: list[str],
487
    proc: subprocess.CompletedProcess,
488
    dest: Path,
489
    dependent_operations: list[str],
490
) -> Optional[list[dict[str, Any]]]:
491
    """Guard for dest-dependent steps behind an untimed precondition clone.
492
493
    Returns None when the clone produced a usable dest. Otherwise: one
494
    failure row recording the clone, then a skip row per dependent step
495
    (reason "prior_step_failed:clone") β€” a failed clone is recorded data and
496
    the rest of that run is an honest gap, never a crash into a missing dest.
497
    """
498
    if proc.returncode == 0 and dest.is_dir():
499
        return None
500
    failure: dict[str, Any] = {
501
        **meta,
502
        "scenario": scenario,
503
        "operation": f"{subject}.clone",
504
        "run": run_index,
505
        "subject": subject,
506
        "subject_kind": SUBJECT_KINDS.get(subject, subject),
507
        # The precondition clone is untimed; 0.0 records the failure, not a
508
        # latency measurement.
509
        "elapsed_ms": 0.0,
510
        "returncode": proc.returncode if proc.returncode != 0 else 1,
511
        "command": command,
512
        "note": "untimed precondition clone failed; elapsed_ms is not a measurement",
513
    }
514
    stderr_text = (proc.stderr or "").strip() if isinstance(proc.stderr, str) else ""
515
    if not stderr_text and proc.returncode == 0:
516
        stderr_text = f"clone exited 0 but dest missing: {dest}"
517
    if stderr_text:
518
        failure["stderr"] = stderr_text[-4000:]
519
    rows = [failure]
520
    for operation in dependent_operations:
521
        rows.append(
522
            skip_row(meta, scenario, subject, run_index, "prior_step_failed:clone", operation=operation)
523
        )
524
    return rows
525
526
527
def run_git_subject(
528
    args: argparse.Namespace,
529
    meta: dict[str, Any],
530
    rows: list[dict[str, Any]],
531
    shaper: oakbench_netshape.NetnsShaper,
532
    workdir: Path,
533
) -> None:
534
    git_bin = shutil.which(args.git_bin)
535
    if git_bin is None:
536
        for run_index in range(args.runs):
537
            rows.append(skip_row(meta, args.scenario, "git", run_index, f"missing_binary:{args.git_bin}"))
538
        return
539
    if args.scenario == "netshape_mount_cold":
540
        for run_index in range(args.runs):
541
            rows.append(skip_row(meta, args.scenario, "git", run_index, "mount_scenario_oak_only"))
542
        return
543
544
    served_root = workdir / "git-served"
545
    served_root.mkdir(parents=True, exist_ok=True)
546
    refs_count = args.refs if args.scenario == "netshape_fetch_negotiation_refs100k" else 0
547
    history_commits = 8 if args.scenario == "netshape_log_follow_cold" else 1
548
    make_served_git_repo(git_bin, workdir, served_root, refs_count, history_commits=history_commits)
549
    server = GitHTTPServer(served_root).start()
550
    proxy = CountingProxy("127.0.0.1", server.server_port, listen_host=oakbench_netshape.HOST_ADDR).start()
551
    url = f"http://{oakbench_netshape.HOST_ADDR}:{proxy.address[1]}/repo.git"
552
    ns = shaper.name
553
    try:
554
        for run_index in range(args.runs):
555
            dest = workdir / f"git-run-{run_index}"
556
557
            def in_ns(command: list[str]) -> list[str]:
558
                return oakbench_netshape.netns_exec(ns, command)
559
560
            def precondition_clone(dependent_operations: list[str], timeout: float = 600.0) -> bool:
561
                command = in_ns([git_bin, "clone", url, str(dest)])
562
                proc = run_untimed(command, timeout=timeout)
563
                failed = precondition_clone_failed_rows(
564
                    meta, args.scenario, "git", run_index, command, proc, dest, dependent_operations,
565
                )
566
                if failed is not None:
567
                    rows.extend(failed)
568
                    return False
569
                return True
570
571
            if args.scenario == "netshape_clone_cold":
572
                rows.append(timed_row(meta, args, "git", run_index, "git.clone",
573
                                      in_ns([git_bin, "clone", url, str(dest)]), workdir, proxy))
574
            elif args.scenario == "netshape_pull_uptodate":
575
                if not precondition_clone(["git.pull"]):
576
                    continue
577
                rows.append(timed_row(meta, args, "git", run_index, "git.pull",
578
                                      in_ns([git_bin, "-C", str(dest), "pull"]), workdir, proxy))
579
            elif args.scenario == "netshape_push_small_delta":
580
                if not precondition_clone(["git.push"]):
581
                    continue
582
                (dest / "delta.txt").write_text(f"small delta run {run_index}\n")
583
                run_untimed([git_bin, "-C", str(dest), "add", "-A"])
584
                run_untimed([git_bin, "-C", str(dest), "commit", "-m", f"delta {run_index}"])
585
                rows.append(timed_row(meta, args, "git", run_index, "git.push",
586
                                      in_ns([git_bin, "-C", str(dest), "push", "origin", "HEAD"]), workdir, proxy))
587
            elif args.scenario == "netshape_fetch_negotiation_refs100k":
588
                if not precondition_clone(["git.fetch"], timeout=1800.0):
589
                    continue
590
                rows.append(timed_row(meta, args, "git", run_index, "git.fetch",
591
                                      in_ns([git_bin, "-C", str(dest), "fetch", "origin"]), workdir, proxy,
592
                                      extra={"refs_seeded": refs_count}))
593
            elif args.scenario == "netshape_log_follow_cold":
594
                clone_cmd = in_ns([git_bin, "clone", "--filter=blob:none", url, str(dest)])
595
                log_cmd = in_ns([git_bin, "-C", str(dest), "log", "--follow", "--", "src/main.py"])
596
                rows.append(timed_sequence_row(
597
                    meta,
598
                    args,
599
                    "git",
600
                    run_index,
601
                    "git.log.follow",
602
                    [clone_cmd, log_cmd],
603
                    workdir,
604
                    proxy,
605
                    extra={
606
                        "history_path": "src/main.py",
607
                        "history_commits_seeded": history_commits,
608
                        "ttfd_semantics": "cold_network_acquire_then_file_history_query",
609
                    },
610
                ))
611
            elif args.scenario == "netshape_task_loop":
612
                if not precondition_clone(["loop.push"] * TASK_LOOP_ITERATIONS):
613
                    continue
614
                for iteration in range(TASK_LOOP_ITERATIONS):
615
                    (dest / "loop.txt").write_text(f"iteration {iteration} run {run_index}\n")
616
                    run_untimed([git_bin, "-C", str(dest), "add", "-A"])
617
                    run_untimed([git_bin, "-C", str(dest), "commit", "-m", f"loop {iteration}"])
618
                    rows.append(timed_row(meta, args, "git", run_index, "loop.push",
619
                                          in_ns([git_bin, "-C", str(dest), "push", "origin", "HEAD"]),
620
                                          workdir, proxy, extra={"iteration": iteration}))
621
    finally:
622
        proxy.stop()
623
        server.stop()
624
625
626
def run_oak_subject(
627
    args: argparse.Namespace,
628
    meta: dict[str, Any],
629
    rows: list[dict[str, Any]],
630
    shaper: oakbench_netshape.NetnsShaper,
631
    workdir: Path,
632
) -> None:
633
    server_cmd = os.environ.get(OAK_LOCAL_SERVER_ENV, "").strip()
634
    if not server_cmd:
635
        for run_index in range(args.runs):
636
            rows.append(skip_row(
637
                meta, args.scenario, "oak", run_index,
638
                f"oak_local_server_missing: set {OAK_LOCAL_SERVER_ENV} to a self-hostable "
639
                "oak server command ({host} {port} {root} placeholders)",
640
            ))
641
        return
642
    oak_bin = shutil.which(args.oak_bin)
643
    if oak_bin is None:
644
        for run_index in range(args.runs):
645
            rows.append(skip_row(meta, args.scenario, "oak", run_index, f"missing_binary:{args.oak_bin}"))
646
        return
647
648
    served_root = workdir / "oak-served"
649
    served_root.mkdir(parents=True, exist_ok=True)
650
    port = free_port()
651
    try:
652
        server_argv = local_server_argv(
653
            server_cmd,
654
            host="127.0.0.1",
655
            port=port,
656
            root=served_root,
657
        )
658
        server_proc = subprocess.Popen(
659
            server_argv,
660
            env=base_env(),
661
            stdout=subprocess.DEVNULL,
662
            stderr=subprocess.DEVNULL,
663
        )
664
    except (OSError, ValueError) as exc:
665
        for run_index in range(args.runs):
666
            rows.append(skip_row(meta, args.scenario, "oak", run_index, f"oak_local_server_failed_to_start:{exc}"))
667
        return
668
    proxy: Optional[CountingProxy] = None
669
    try:
670
        if not wait_for_port("127.0.0.1", port):
671
            for run_index in range(args.runs):
672
                rows.append(skip_row(meta, args.scenario, "oak", run_index, "oak_local_server_failed_to_start"))
673
            return
674
        # Untimed precondition: the server starts over an EMPTY served_root, so
675
        # the repo the measured runs clone/pull/push must be seeded first
676
        # (directly against the server, off the shaped pipe β€” setup is never
677
        # the measurement). A failed seed is a per-run skip, never a crash.
678
        history_commits = 8 if args.scenario == "netshape_log_follow_cold" else 1
679
        seeded, seed_reason = seed_oak_served_repo(
680
            oak_bin,
681
            f"http://127.0.0.1:{port}/{OAK_SERVED_REPO}",
682
            workdir,
683
            history_commits=history_commits,
684
        )
685
        if not seeded:
686
            for run_index in range(args.runs):
687
                rows.append(skip_row(meta, args.scenario, "oak", run_index, seed_reason))
688
            return
689
        proxy = CountingProxy("127.0.0.1", port, listen_host=oakbench_netshape.HOST_ADDR).start()
690
        url = f"http://{oakbench_netshape.HOST_ADDR}:{proxy.address[1]}/{OAK_SERVED_REPO}"
691
        ns = shaper.name
692
        for run_index in range(args.runs):
693
            dest = workdir / f"oak-run-{run_index}"
694
695
            def in_ns(command: list[str]) -> list[str]:
696
                return oakbench_netshape.netns_exec(ns, command)
697
698
            def precondition_clone(dependent_operations: list[str]) -> bool:
699
                command = in_ns([oak_bin, "clone", url, str(dest)])
700
                proc = run_untimed(command)
701
                failed = precondition_clone_failed_rows(
702
                    meta, args.scenario, "oak", run_index, command, proc, dest, dependent_operations,
703
                )
704
                if failed is not None:
705
                    rows.extend(failed)
706
                    return False
707
                return True
708
709
            if args.scenario == "netshape_clone_cold":
710
                rows.append(timed_row(meta, args, "oak", run_index, "oak.clone",
711
                                      in_ns([oak_bin, "clone", url, str(dest)]), workdir, proxy))
712
            elif args.scenario == "netshape_pull_uptodate":
713
                if not precondition_clone(["oak.pull"]):
714
                    continue
715
                rows.append(timed_row(meta, args, "oak", run_index, "oak.pull",
716
                                      in_ns([oak_bin, "pull"]), dest, proxy))
717
            elif args.scenario == "netshape_push_small_delta":
718
                if not precondition_clone(["oak.push"]):
719
                    continue
720
                (dest / "delta.txt").write_text(f"small delta run {run_index}\n")
721
                run_untimed([oak_bin, "commit", "--no-verify"], cwd=dest)
722
                rows.append(timed_row(meta, args, "oak", run_index, "oak.push",
723
                                      in_ns([oak_bin, "push"]), dest, proxy))
724
            elif args.scenario == "netshape_fetch_negotiation_refs100k":
725
                if not precondition_clone(["oak.fetch"]):
726
                    continue
727
                rows.append(timed_row(meta, args, "oak", run_index, "oak.fetch",
728
                                      in_ns([oak_bin, "fetch"]), dest, proxy))
729
            elif args.scenario == "netshape_log_follow_cold":
730
                clone_cmd = in_ns([oak_bin, "clone", url, str(dest)])
731
                log_cmd = in_ns([oak_bin, "log", "src/main.py"])
732
                rows.append(timed_sequence_row(
733
                    meta,
734
                    args,
735
                    "oak",
736
                    run_index,
737
                    "oak.log.follow",
738
                    [clone_cmd, log_cmd],
739
                    workdir,
740
                    proxy,
741
                    extra={
742
                        "history_path": "src/main.py",
743
                        "history_commits_seeded": history_commits,
744
                        "ttfd_semantics": "cold_network_acquire_then_file_history_query",
745
                    },
746
                ))
747
            elif args.scenario == "netshape_mount_cold":
748
                rows.append(timed_row(meta, args, "oak", run_index, "oak.mount",
749
                                      in_ns([oak_bin, "mount", url, str(dest)]), workdir, proxy))
750
                if dest.exists():
751
                    run_untimed([oak_bin, "mount", "end", str(dest)])
752
            elif args.scenario == "netshape_task_loop":
753
                if not precondition_clone(["loop.push"] * TASK_LOOP_ITERATIONS):
754
                    continue
755
                for iteration in range(TASK_LOOP_ITERATIONS):
756
                    (dest / "loop.txt").write_text(f"iteration {iteration} run {run_index}\n")
757
                    run_untimed([oak_bin, "commit", "--no-verify"], cwd=dest)
758
                    rows.append(timed_row(meta, args, "oak", run_index, "loop.push",
759
                                          in_ns([oak_bin, "push"]), dest, proxy,
760
                                          extra={"iteration": iteration}))
761
    finally:
762
        if proxy is not None:
763
            proxy.stop()
764
        server_proc.terminate()
765
        try:
766
            server_proc.wait(timeout=10.0)
767
        except subprocess.TimeoutExpired:
768
            server_proc.kill()
769
770
771
def run_shaped_campaign(
772
    args: argparse.Namespace,
773
    profile: oakbench_netshape.NetshapeProfile,
774
    subjects: list[str],
775
    meta: dict[str, Any],
776
    rows: list[dict[str, Any]],
777
) -> None:
778
    workdir = Path(tempfile.mkdtemp(prefix="oak-netshape-"))
779
    shaper = oakbench_netshape.NetnsShaper()
780
    shaper.create(profile)
781
    try:
782
        selftest = shaper.self_test(profile)
783
        rows.append(selftest_row(meta, args, selftest))
784
        if not selftest["within_tolerance"]:
785
            # The instrument failed its own calibration; nothing measured
786
            # through it is evidence.
787
            for subject in subjects:
788
                for run_index in range(args.runs):
789
                    rows.append(skip_row(
790
                        meta, args.scenario, subject, run_index,
791
                        f"netshape_selftest_out_of_tolerance: configured "
792
                        f"{selftest['configured_rtt_ms']}ms measured {selftest['measured_rtt_ms']}ms",
793
                    ))
794
            return
795
        for subject in subjects:
796
            if subject == "git":
797
                run_git_subject(args, meta, rows, shaper, workdir)
798
            else:
799
                run_oak_subject(args, meta, rows, shaper, workdir)
800
    finally:
801
        shaper.teardown()
802
        shutil.rmtree(workdir, ignore_errors=True)
803
804
805
def exit_code_for_rows(rows: list[dict[str, Any]]) -> int:
806
    """Exit decision over the emitted row stream (pure, unit-testable).
807
808
    Failures (any returncode other than 0/77, on ANY row including the
809
    self-test) dominate. The all-skipped verdict is then computed over
810
    SUBJECT MEASUREMENT rows only: diagnostic rows (operation
811
    "netshape.selftest", subject_kind "harness") prove the instrument worked,
812
    not that anything was measured, so a green self-test plus all-skipped
813
    subject rows β€” or a self-test with no subject rows at all β€” is still
814
    EXIT_ALL_SKIPPED.
815
    """
816
    failures = [row for row in rows if row.get("returncode") not in (0, SKIP_RETURNCODE)]
817
    if failures:
818
        return EXIT_FAILURES
819
    subject_rows = [
820
        row for row in rows
821
        if row.get("operation") != "netshape.selftest" and row.get("subject_kind") != "harness"
822
    ]
823
    if rows and not subject_rows:
824
        return EXIT_ALL_SKIPPED  # nothing was measured, only diagnostics
825
    if subject_rows and all(row.get("skipped") for row in subject_rows):
826
        return EXIT_ALL_SKIPPED
827
    return EXIT_OK
828
829
830
def main(argv: Optional[list[str]] = None) -> int:
831
    args = parse_args(argv)
832
    try:
833
        profiles = oakbench_netshape.load_netshapes(args.netshapes)
834
    except oakbench_netshape.NetshapeConfigError as exc:
835
        print(f"error: {exc}", file=sys.stderr)
836
        return EXIT_USAGE
837
    if args.profile not in profiles:
838
        known = ", ".join(sorted(profiles))
839
        print(f"error: unknown netshape profile {args.profile!r}; known: {known}", file=sys.stderr)
840
        return EXIT_USAGE
841
    subjects = [item.strip() for item in args.subjects.split(",") if item.strip()]
842
    unknown_subjects = [item for item in subjects if item not in SUBJECT_KINDS]
843
    if not subjects or unknown_subjects:
844
        print(f"error: --subjects must be a non-empty subset of git,oak; got {args.subjects!r}", file=sys.stderr)
845
        return EXIT_USAGE
846
    if args.runs < 1:
847
        print("error: --runs must be >= 1", file=sys.stderr)
848
        return EXIT_USAGE
849
850
    bench_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
851
    meta = metadata(bench_id, args)
852
    rows: list[dict[str, Any]] = []
853
854
    available, reason = oakbench_netshape.netshape_available()
855
    if not available:
856
        for subject in subjects:
857
            for run_index in range(args.runs):
858
                rows.append(skip_row(meta, args.scenario, subject, run_index, f"netshape_unavailable:{reason}"))
859
    else:
860
        run_shaped_campaign(args, profiles[args.profile], subjects, meta, rows)
861
862
    path = write_rows(args.results, bench_id, rows)
863
    print(f"[result] {path}")
864
865
    return exit_code_for_rows(rows)
866
867
868
if __name__ == "__main__":
869
    raise SystemExit(main())